@outputai/llm 0.9.1-next.ca5a2f9.0 → 0.9.1
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": "@outputai/llm",
|
|
3
|
-
"version": "0.9.1
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Framework abstraction to interact with LLM models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"gray-matter": "4.0.3",
|
|
14
14
|
"liquidjs": "10.25.7",
|
|
15
15
|
"undici": "8.5.0",
|
|
16
|
-
"@outputai/core": "0.9.1
|
|
16
|
+
"@outputai/core": "0.9.1"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"@ai-sdk/amazon-bedrock": "4.0.111",
|
package/src/ai_provider.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FatalError, ValidationError, z } from '@outputai/core';
|
|
2
|
-
import {
|
|
2
|
+
import { EnvHttpProxyAgent, fetch } from 'undici';
|
|
3
3
|
// providers
|
|
4
4
|
import { createAnthropic } from '@ai-sdk/anthropic';
|
|
5
5
|
import { createAzure } from '@ai-sdk/azure';
|
|
@@ -8,10 +8,11 @@ import { createOpenAI } from '@ai-sdk/openai';
|
|
|
8
8
|
import { createPerplexity } from '@ai-sdk/perplexity';
|
|
9
9
|
import { createVertex } from '@ai-sdk/google-vertex';
|
|
10
10
|
|
|
11
|
-
/** This custom dispatcher has longer timeouts */
|
|
12
|
-
const customDispatcher = new
|
|
11
|
+
/** This custom dispatcher has longer timeouts. */
|
|
12
|
+
const customDispatcher = new EnvHttpProxyAgent( {
|
|
13
13
|
headersTimeout: 15 * 60 * 1000, // 15 min
|
|
14
|
-
bodyTimeout: 15 * 60 * 1000
|
|
14
|
+
bodyTimeout: 15 * 60 * 1000,
|
|
15
|
+
allowH2: false // Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299
|
|
15
16
|
} );
|
|
16
17
|
|
|
17
18
|
/** This custom fetch instance uses the custom dispatcher */
|
package/src/ai_provider.spec.js
CHANGED
|
@@ -18,7 +18,14 @@ const makeProviderModules = () => Object.fromEntries(
|
|
|
18
18
|
] )
|
|
19
19
|
);
|
|
20
20
|
|
|
21
|
-
const
|
|
21
|
+
const undiciSpies = vi.hoisted( () => ( {
|
|
22
|
+
EnvHttpProxyAgent: vi.fn(),
|
|
23
|
+
fetch: vi.fn()
|
|
24
|
+
} ) );
|
|
25
|
+
|
|
26
|
+
const importWithMockedProviders = async ( {
|
|
27
|
+
modules = makeProviderModules()
|
|
28
|
+
} = {} ) => {
|
|
22
29
|
await vi.resetModules();
|
|
23
30
|
|
|
24
31
|
for ( const { pkg, exportName } of SHIPPED_PROVIDERS ) {
|
|
@@ -27,6 +34,20 @@ const importWithMockedProviders = async ( modules = makeProviderModules() ) => {
|
|
|
27
34
|
} ) );
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
vi.doMock( 'undici', async importOriginal => {
|
|
38
|
+
const actual = await importOriginal();
|
|
39
|
+
undiciSpies.EnvHttpProxyAgent.mockImplementation( function EnvHttpProxyAgent( options ) {
|
|
40
|
+
return new actual.EnvHttpProxyAgent( options );
|
|
41
|
+
} );
|
|
42
|
+
undiciSpies.fetch.mockResolvedValue( new actual.Response( null, { status: 204 } ) );
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
...actual,
|
|
46
|
+
EnvHttpProxyAgent: undiciSpies.EnvHttpProxyAgent,
|
|
47
|
+
fetch: undiciSpies.fetch
|
|
48
|
+
};
|
|
49
|
+
} );
|
|
50
|
+
|
|
30
51
|
return {
|
|
31
52
|
modules,
|
|
32
53
|
...( await import( './ai_provider.js' ) )
|
|
@@ -37,6 +58,9 @@ afterEach( () => {
|
|
|
37
58
|
for ( const { pkg } of SHIPPED_PROVIDERS ) {
|
|
38
59
|
vi.doUnmock( pkg );
|
|
39
60
|
}
|
|
61
|
+
vi.doUnmock( 'undici' );
|
|
62
|
+
undiciSpies.EnvHttpProxyAgent.mockReset();
|
|
63
|
+
undiciSpies.fetch.mockReset();
|
|
40
64
|
vi.resetModules();
|
|
41
65
|
vi.restoreAllMocks();
|
|
42
66
|
} );
|
|
@@ -59,6 +83,41 @@ describe( 'getProvider', () => {
|
|
|
59
83
|
expect( provider ).toMatchObject( { name, options: { fetch: expect.any( Function ) } } );
|
|
60
84
|
expect( modules[pkg][exportName] ).toHaveBeenCalledWith( { fetch: expect.any( Function ) } );
|
|
61
85
|
}
|
|
86
|
+
|
|
87
|
+
expect( undiciSpies.EnvHttpProxyAgent ).toHaveBeenCalledWith( {
|
|
88
|
+
headersTimeout: 15 * 60 * 1000,
|
|
89
|
+
bodyTimeout: 15 * 60 * 1000,
|
|
90
|
+
allowH2: false
|
|
91
|
+
} );
|
|
92
|
+
} );
|
|
93
|
+
|
|
94
|
+
it( 'passes the custom dispatcher through injected fetch', async () => {
|
|
95
|
+
const { getProvider } = await importWithMockedProviders();
|
|
96
|
+
const provider = getProvider( 'openai' );
|
|
97
|
+
const init = { method: 'POST', body: 'body' };
|
|
98
|
+
|
|
99
|
+
await provider.options.fetch( 'https://api.example.test', init );
|
|
100
|
+
|
|
101
|
+
expect( undiciSpies.fetch ).toHaveBeenCalledWith(
|
|
102
|
+
'https://api.example.test',
|
|
103
|
+
{
|
|
104
|
+
...init,
|
|
105
|
+
dispatcher: undiciSpies.EnvHttpProxyAgent.mock.results[0].value
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
} );
|
|
109
|
+
|
|
110
|
+
it( 'allows injected fetch callers to override the dispatcher', async () => {
|
|
111
|
+
const { getProvider } = await importWithMockedProviders();
|
|
112
|
+
const provider = getProvider( 'openai' );
|
|
113
|
+
const dispatcher = { name: 'custom-dispatcher' };
|
|
114
|
+
|
|
115
|
+
await provider.options.fetch( 'https://api.example.test', { dispatcher } );
|
|
116
|
+
|
|
117
|
+
expect( undiciSpies.fetch ).toHaveBeenCalledWith(
|
|
118
|
+
'https://api.example.test',
|
|
119
|
+
{ dispatcher }
|
|
120
|
+
);
|
|
62
121
|
} );
|
|
63
122
|
|
|
64
123
|
it( 'can import and initialize all installed shipped providers', async () => {
|
|
@@ -100,7 +159,7 @@ describe( 'getProvider', () => {
|
|
|
100
159
|
modules['@ai-sdk/openai'].createOpenAI.mockImplementation( () => {
|
|
101
160
|
throw new Error( 'Missing OpenAI API key' );
|
|
102
161
|
} );
|
|
103
|
-
const { getProvider } = await importWithMockedProviders( modules );
|
|
162
|
+
const { getProvider } = await importWithMockedProviders( { modules } );
|
|
104
163
|
|
|
105
164
|
expect( () => getProvider( 'openai' ) ).toThrow(
|
|
106
165
|
'Failed to initialize provider "openai": Missing OpenAI API key'
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { Logger } from '@outputai/core';
|
|
2
|
+
import { EnvHttpProxyAgent, fetch } from 'undici';
|
|
2
3
|
|
|
4
|
+
const logger = Logger.createLogger( 'LLM' );
|
|
3
5
|
const costTableUrl = 'https://models.dev/api.json';
|
|
4
6
|
const cacheTTL = 1000 * 60 * 60 * 24; // 1 day
|
|
5
7
|
|
|
8
|
+
/* Ignore HTTP/2. Check: https://github.com/growthxai/output/issues/299 */
|
|
9
|
+
const dispatcher = new EnvHttpProxyAgent( { allowH2: false } );
|
|
10
|
+
|
|
6
11
|
export const cache = {
|
|
7
12
|
content: null,
|
|
8
13
|
expiresAt: 0
|
|
@@ -21,20 +26,36 @@ const buildModelMap = data => {
|
|
|
21
26
|
return map;
|
|
22
27
|
};
|
|
23
28
|
|
|
29
|
+
const buildErrorMessage = cause => `Error "${cause}" when fetching models pricing at ${costTableUrl}`;
|
|
30
|
+
|
|
24
31
|
export const fetchModelsPricing = async () => {
|
|
25
32
|
if ( cache.content && cache.expiresAt > Date.now() ) {
|
|
26
33
|
return cache.content;
|
|
27
34
|
}
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
|
|
36
|
+
const state = { errorMessage: null, table: null };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch( costTableUrl, { dispatcher } );
|
|
40
|
+
if ( res.ok ) {
|
|
41
|
+
state.table = await res.json();
|
|
42
|
+
} else {
|
|
43
|
+
state.errorMessage = buildErrorMessage( res.status );
|
|
44
|
+
}
|
|
45
|
+
} catch ( error ) {
|
|
46
|
+
state.errorMessage = buildErrorMessage( error.code ?? error.name ?? error.constructor.name );
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if ( state.errorMessage ) {
|
|
30
50
|
if ( cache.content ) {
|
|
31
|
-
|
|
51
|
+
logger.warn( state.errorMessage + ', falling back to stale cache' );
|
|
32
52
|
return cache.content;
|
|
33
53
|
}
|
|
34
|
-
|
|
54
|
+
logger.error( state.errorMessage );
|
|
35
55
|
return null;
|
|
36
56
|
}
|
|
37
|
-
|
|
57
|
+
|
|
58
|
+
cache.content = buildModelMap( state.table );
|
|
38
59
|
cache.expiresAt = Date.now() + cacheTTL;
|
|
39
60
|
return cache.content;
|
|
40
61
|
};
|
|
@@ -4,6 +4,16 @@ import { fileURLToPath } from 'url';
|
|
|
4
4
|
import { dirname, join } from 'path';
|
|
5
5
|
import { fetchModelsPricing, cache } from './fetch_models_pricing.js';
|
|
6
6
|
|
|
7
|
+
const fetchMock = vi.hoisted( () => vi.fn() );
|
|
8
|
+
const EnvHttpProxyAgentMock = vi.hoisted( () => vi.fn( function EnvHttpProxyAgent( options ) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
} ) );
|
|
11
|
+
|
|
12
|
+
vi.mock( 'undici', () => ( {
|
|
13
|
+
EnvHttpProxyAgent: EnvHttpProxyAgentMock,
|
|
14
|
+
fetch: fetchMock
|
|
15
|
+
} ) );
|
|
16
|
+
|
|
7
17
|
const __dirname = dirname( fileURLToPath( import.meta.url ) );
|
|
8
18
|
const fixturePath = join( __dirname, 'fixtures', 'models_api_light.json' );
|
|
9
19
|
const fixture = JSON.parse( readFileSync( fixturePath, 'utf8' ) );
|
|
@@ -14,8 +24,7 @@ const okResponse = data => ( {
|
|
|
14
24
|
json: () => Promise.resolve( data )
|
|
15
25
|
} );
|
|
16
26
|
const stubFetch = response => {
|
|
17
|
-
|
|
18
|
-
vi.stubGlobal( 'fetch', fetchMock );
|
|
27
|
+
fetchMock.mockResolvedValueOnce( response );
|
|
19
28
|
return fetchMock;
|
|
20
29
|
};
|
|
21
30
|
|
|
@@ -23,7 +32,7 @@ describe( 'fetchModelsPricing', () => {
|
|
|
23
32
|
beforeEach( () => {
|
|
24
33
|
cache.content = null;
|
|
25
34
|
cache.expiresAt = 0;
|
|
26
|
-
|
|
35
|
+
fetchMock.mockReset();
|
|
27
36
|
} );
|
|
28
37
|
|
|
29
38
|
it( 'returns a Map of model costs when fetch succeeds', async () => {
|
|
@@ -31,7 +40,8 @@ describe( 'fetchModelsPricing', () => {
|
|
|
31
40
|
|
|
32
41
|
const result = await fetchModelsPricing();
|
|
33
42
|
|
|
34
|
-
expect(
|
|
43
|
+
expect( EnvHttpProxyAgentMock ).toHaveBeenCalledWith( { allowH2: false } );
|
|
44
|
+
expect( fetchMock ).toHaveBeenCalledWith( costTableUrl, { dispatcher: EnvHttpProxyAgentMock.mock.results[0].value } );
|
|
35
45
|
expect( result ).toBeInstanceOf( Map );
|
|
36
46
|
expect( result.size ).toBeGreaterThan( 0 );
|
|
37
47
|
const firstModel = Object.values( fixture )[0];
|
|
@@ -78,6 +88,43 @@ describe( 'fetchModelsPricing', () => {
|
|
|
78
88
|
expect( result.size ).toBeGreaterThan( 0 );
|
|
79
89
|
} );
|
|
80
90
|
|
|
91
|
+
it( 'returns null when fetch rejects and no cache', async () => {
|
|
92
|
+
const error = new Error( 'network failure' );
|
|
93
|
+
fetchMock.mockRejectedValueOnce( error );
|
|
94
|
+
|
|
95
|
+
const result = await fetchModelsPricing();
|
|
96
|
+
|
|
97
|
+
expect( result ).toBeNull();
|
|
98
|
+
} );
|
|
99
|
+
|
|
100
|
+
it( 'returns stale cache when fetch rejects but cache exists', async () => {
|
|
101
|
+
stubFetch( okResponse( fixture ) );
|
|
102
|
+
const staleCache = await fetchModelsPricing();
|
|
103
|
+
cache.expiresAt = 0; // force refetch so we hit the catch path
|
|
104
|
+
|
|
105
|
+
const error = Object.assign( new Error( 'socket closed' ), { code: 'UND_ERR_SOCKET' } );
|
|
106
|
+
fetchMock.mockRejectedValueOnce( error );
|
|
107
|
+
|
|
108
|
+
const result = await fetchModelsPricing();
|
|
109
|
+
|
|
110
|
+
expect( result ).toBe( staleCache );
|
|
111
|
+
} );
|
|
112
|
+
|
|
113
|
+
it( 'returns stale cache when response JSON parsing fails but cache exists', async () => {
|
|
114
|
+
stubFetch( okResponse( fixture ) );
|
|
115
|
+
const staleCache = await fetchModelsPricing();
|
|
116
|
+
cache.expiresAt = 0; // force refetch so parsing errors can fall back to cache
|
|
117
|
+
|
|
118
|
+
stubFetch( {
|
|
119
|
+
ok: true,
|
|
120
|
+
json: () => Promise.reject( new SyntaxError( 'Unexpected token' ) )
|
|
121
|
+
} );
|
|
122
|
+
|
|
123
|
+
const result = await fetchModelsPricing();
|
|
124
|
+
|
|
125
|
+
expect( result ).toBe( staleCache );
|
|
126
|
+
} );
|
|
127
|
+
|
|
81
128
|
it( 'returns cached Map when cache is still valid', async () => {
|
|
82
129
|
const fetchMock = stubFetch( okResponse( fixture ) );
|
|
83
130
|
|