@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-next.ca5a2f9.0",
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-next.ca5a2f9.0"
16
+ "@outputai/core": "0.9.1"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@ai-sdk/amazon-bedrock": "4.0.111",
@@ -1,5 +1,5 @@
1
1
  import { FatalError, ValidationError, z } from '@outputai/core';
2
- import { Agent, fetch } from 'undici';
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 Agent( {
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 */
@@ -18,7 +18,14 @@ const makeProviderModules = () => Object.fromEntries(
18
18
  ] )
19
19
  );
20
20
 
21
- const importWithMockedProviders = async ( modules = makeProviderModules() ) => {
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
- const res = await fetch( costTableUrl );
29
- if ( !res.ok ) {
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
- Logger.warn( `Error ${res.status} when fetching models pricing at ${costTableUrl}, falling back to stale cache`, { namespace: 'LLM' } );
51
+ logger.warn( state.errorMessage + ', falling back to stale cache' );
32
52
  return cache.content;
33
53
  }
34
- Logger.error( `Error ${res.status} when fetching models pricing at ${costTableUrl}`, { namespace: 'LLM' } );
54
+ logger.error( state.errorMessage );
35
55
  return null;
36
56
  }
37
- cache.content = buildModelMap( await res.json() );
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
- const fetchMock = vi.fn().mockResolvedValue( response );
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
- vi.unstubAllGlobals();
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( fetchMock ).toHaveBeenCalledWith( costTableUrl );
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