@output.ai/llm 0.2.1 → 0.2.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/llm",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Framework abstraction to interact with LLM models",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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
- const traceWrapper = async ( traceId, fn ) => {
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 result = await fn();
10
- Tracing.addEventEnd( { id: traceId, details: result } );
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: loadedPrompt } );
69
+ const traceId = startTrace( 'generateText', { prompt, variables, loadedPrompt } );
56
70
 
57
- return traceWrapper( traceId, async () =>
58
- AI.generateText( extraAiSdkOptionsFromPrompt( loadedPrompt ) ).then( r => r.text )
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, prompt: loadedPrompt } );
92
+ const traceId = startTrace( 'generateObject', { ...args, schema: z.toJSONSchema( schema ), loadedPrompt } );
78
93
 
79
- return traceWrapper( traceId, async () =>
80
- AI.generateObject( {
81
- output: 'object',
82
- schema,
83
- schemaName,
84
- schemaDescription,
85
- ...extraAiSdkOptionsFromPrompt( loadedPrompt )
86
- } ).then( r => r.object )
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, prompt: loadedPrompt } );
121
+ const traceId = startTrace( 'generateArray', { ...args, schema: z.toJSONSchema( schema ), loadedPrompt } );
106
122
 
107
- return traceWrapper( traceId, async () =>
108
- AI.generateObject( {
109
- output: 'array',
110
- schema,
111
- schemaName,
112
- schemaDescription,
113
- ...extraAiSdkOptionsFromPrompt( loadedPrompt )
114
- } ).then( r => r.object )
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, prompt: loadedPrompt } );
148
+ const traceId = startTrace( 'generateEnum', { ...args, loadedPrompt } );
132
149
 
133
- return traceWrapper( traceId, async () =>
134
- AI.generateObject( {
135
- output: 'enum',
136
- enum: _enum,
137
- ...extraAiSdkOptionsFromPrompt( loadedPrompt )
138
- } ).then( r => r.object )
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
  }
@@ -1,121 +1,42 @@
1
- import { join, dirname } from 'path';
2
- import { readFileSync, readdirSync } from 'node:fs';
3
- import { fileURLToPath } from 'node:url';
1
+ import { join } from 'path';
2
+ import { readdirSync, readFileSync } from 'node:fs';
3
+ import { resolveInvocationDir } from '@output.ai/core/utils';
4
4
 
5
- function extractFilePath( fileUrlMatch, parenMatch, directMatch ) {
6
- if ( fileUrlMatch ) {
7
- return fileURLToPath( 'file://' + fileUrlMatch[1] );
8
- }
9
- if ( parenMatch ) {
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' );
5
+ const scanDir = dir => {
6
+ try {
7
+ return readdirSync( dir, { withFileTypes: true } );
8
+ } catch ( error ) {
9
+ throw new Error( `Error scanning directory "${dir}"`, { cause: error } );
23
10
  }
11
+ };
24
12
 
25
- const lines = stack.split( '\n' );
26
-
27
- // Search through stack to find first valid file path that is NOT in SDK
28
- // Skip first 2 lines (Error message and getInvocationDir itself)
29
- for ( const line of lines.slice( 2 ) ) {
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
- }
13
+ const loadFile = path => {
14
+ try {
15
+ return readFileSync( path, 'utf-8' );
16
+ } catch ( error ) {
17
+ throw new Error( `Error reading file "${path}"`, { cause: error } );
56
18
  }
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
19
  };
63
20
 
64
21
  /**
65
22
  * Recursively search for a file by its name and load its content.
66
23
  *
67
24
  * @param {string} name - Name of the file load its content
68
- * @param {string} [dir=<invocation directory>] - The directory to search for the file
69
- * @param {number} [depth=0] - Current recursion depth
70
- * @param {number} [maxDepth=5] - Maximum recursion depth
25
+ * @param {string} [dir] - The directory to search for the file, defaults to invocation directory
71
26
  * @returns {string | null} - File content or null if not found
72
27
  */
73
- export const loadContent = ( name, dir = getInvocationDir(), depth = 0, maxDepth = 5 ) => {
74
- // Stop recursion if max depth exceeded
75
- if ( depth > maxDepth ) {
76
- return null;
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
- }
28
+ export const loadContent = ( name, dir = resolveInvocationDir() ) => {
29
+ for ( const entry of scanDir( dir ) ) {
30
+ if ( entry.name === name ) {
31
+ return loadFile( join( dir, entry.name ) );
106
32
  }
107
33
 
108
- return null;
109
- } catch ( error ) {
110
- // Only suppress ENOENT (directory doesn't exist) during recursion
111
- // This is expected when searching through nested directories
112
- if ( error.code === 'ENOENT' ) {
113
- return null;
34
+ if ( entry.isDirectory() && !entry.isSymbolicLink() ) {
35
+ const content = loadContent( name, join( dir, entry.name ) );
36
+ if ( content ) {
37
+ return content;
38
+ }
114
39
  }
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
40
  }
41
+ return null;
121
42
  };
@@ -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
- describe( 'getInvocationDir', () => {
8
- it( 'extracts directory from stack trace', () => {
9
- const dir = getInvocationDir();
10
- expect( dir ).toBeTruthy();
11
- expect( typeof dir ).toBe( 'string' );
12
- // Should not contain filename, only directory
13
- expect( dir ).not.toContain( 'load_content.spec.js' );
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
- it( 'returns consistent directory when called multiple times', () => {
17
- const dir1 = getInvocationDir();
18
- const dir2 = getInvocationDir();
19
- expect( dir1 ).toBe( dir2 );
20
- } );
28
+ const dirEntry = ( name, { isDir = false, isLink = false } = {} ) => ( {
29
+ name,
30
+ isDirectory: () => isDir,
31
+ isSymbolicLink: () => isLink
32
+ } );
21
33
 
22
- it( 'returns a valid filesystem path', () => {
23
- const dir = getInvocationDir();
24
- // Should be a non-empty string that looks like a directory path
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 content = loadContent( 'test.txt', tempDir );
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
- const content = loadContent( 'nested.txt', tempDir );
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 content = loadContent( 'nonexistent.txt', tempDir );
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
  } );