@outputai/llm 0.11.1-next.da26845.0 → 0.12.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/package.json +7 -3
- package/src/agent.js +7 -2
- package/src/generate.js +1 -0
- package/src/index.d.ts +14 -6
- package/src/utils/wrap.js +35 -2
- package/src/agent.spec.js +0 -595
- package/src/ai_provider.spec.js +0 -217
- package/src/ai_sdk_options.spec.js +0 -390
- package/src/fixtures/image_response_v7_google_vertex.js +0 -72
- package/src/fixtures/image_response_v7_openai.js +0 -77
- package/src/fixtures/models_api_light.json +0 -675
- package/src/fixtures/models_api_v7_subset.json +0 -259
- package/src/fixtures/stream_response_v7_anthropic.js +0 -389
- package/src/fixtures/stream_response_v7_google_vertex.js +0 -462
- package/src/fixtures/stream_response_v7_openai.js +0 -353
- package/src/fixtures/stream_response_v7_perplexity.js +0 -671
- package/src/fixtures/text_response_v7_anthropic.js +0 -359
- package/src/fixtures/text_response_v7_anthropic_cache.js +0 -719
- package/src/fixtures/text_response_v7_google_vertex.js +0 -435
- package/src/fixtures/text_response_v7_openai.js +0 -347
- package/src/fixtures/text_response_v7_perplexity.js +0 -764
- package/src/generate.spec.js +0 -525
- package/src/prompt/content.spec.js +0 -208
- package/src/prompt/interpolations.spec.js +0 -109
- package/src/prompt/loader.full.spec.js +0 -309
- package/src/prompt/loader.spec.js +0 -258
- package/src/prompt/markup/attributes.spec.js +0 -132
- package/src/prompt/markup/nodes.spec.js +0 -333
- package/src/prompt/markup/tokenizer.spec.js +0 -149
- package/src/prompt/markup/tokens.spec.js +0 -250
- package/src/prompt/validations.spec.js +0 -812
- package/src/utils/cost.full.spec.js +0 -272
- package/src/utils/cost.spec.js +0 -290
- package/src/utils/error_handler.spec.js +0 -230
- package/src/utils/file.spec.js +0 -89
- package/src/utils/image.spec.js +0 -20
- package/src/utils/legacy_cost_attribute.spec.js +0 -278
- package/src/utils/models.spec.js +0 -119
- package/src/utils/models_pricing.spec.js +0 -162
- package/src/utils/skills.spec.js +0 -168
- package/src/utils/sources.spec.js +0 -122
- package/src/utils/stream.spec.js +0 -55
- package/src/utils/tools.spec.js +0 -167
- package/src/utils/usage.full.spec.js +0 -215
- package/src/utils/usage.spec.js +0 -246
- package/src/utils/wrap.spec.js +0 -467
- package/src/validations.spec.js +0 -631
package/src/utils/models.spec.js
DELETED
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
const getProvider = vi.hoisted( () => vi.fn() );
|
|
4
|
-
|
|
5
|
-
vi.mock( '../ai_provider.js', () => ( {
|
|
6
|
-
getProvider
|
|
7
|
-
} ) );
|
|
8
|
-
|
|
9
|
-
import { loadImageModel, loadTextModel } from './models.js';
|
|
10
|
-
|
|
11
|
-
afterEach( () => {
|
|
12
|
-
vi.clearAllMocks();
|
|
13
|
-
} );
|
|
14
|
-
|
|
15
|
-
describe( 'loadTextModel', () => {
|
|
16
|
-
it( 'loads a text model using the prompt provider and model', () => {
|
|
17
|
-
const provider = vi.fn( model => ( { type: 'text-model', model } ) );
|
|
18
|
-
getProvider.mockReturnValue( provider );
|
|
19
|
-
|
|
20
|
-
const result = loadTextModel( {
|
|
21
|
-
config: {
|
|
22
|
-
provider: 'openai',
|
|
23
|
-
model: 'gpt-4o-mini'
|
|
24
|
-
}
|
|
25
|
-
} );
|
|
26
|
-
|
|
27
|
-
expect( getProvider ).toHaveBeenCalledWith( 'openai' );
|
|
28
|
-
expect( provider ).toHaveBeenCalledWith( 'gpt-4o-mini' );
|
|
29
|
-
expect( result ).toEqual( {
|
|
30
|
-
type: 'text-model',
|
|
31
|
-
model: 'gpt-4o-mini'
|
|
32
|
-
} );
|
|
33
|
-
} );
|
|
34
|
-
|
|
35
|
-
it( 'propagates provider lookup errors', () => {
|
|
36
|
-
getProvider.mockImplementation( () => {
|
|
37
|
-
throw new Error( 'Unsupported provider "missing"' );
|
|
38
|
-
} );
|
|
39
|
-
|
|
40
|
-
expect( () => loadTextModel( {
|
|
41
|
-
config: {
|
|
42
|
-
provider: 'missing',
|
|
43
|
-
model: 'model'
|
|
44
|
-
}
|
|
45
|
-
} ) ).toThrow( 'Unsupported provider "missing"' );
|
|
46
|
-
} );
|
|
47
|
-
} );
|
|
48
|
-
|
|
49
|
-
describe( 'loadImageModel', () => {
|
|
50
|
-
it( 'loads an image model using provider.image', () => {
|
|
51
|
-
const textProvider = vi.fn();
|
|
52
|
-
textProvider.image = vi.fn( model => ( { type: 'image-model', model } ) );
|
|
53
|
-
getProvider.mockReturnValue( textProvider );
|
|
54
|
-
|
|
55
|
-
const result = loadImageModel( {
|
|
56
|
-
config: {
|
|
57
|
-
provider: 'openai',
|
|
58
|
-
model: 'gpt-image-1'
|
|
59
|
-
}
|
|
60
|
-
} );
|
|
61
|
-
|
|
62
|
-
expect( getProvider ).toHaveBeenCalledWith( 'openai' );
|
|
63
|
-
expect( textProvider.image ).toHaveBeenCalledWith( 'gpt-image-1' );
|
|
64
|
-
expect( textProvider ).not.toHaveBeenCalled();
|
|
65
|
-
expect( result ).toEqual( {
|
|
66
|
-
type: 'image-model',
|
|
67
|
-
model: 'gpt-image-1'
|
|
68
|
-
} );
|
|
69
|
-
} );
|
|
70
|
-
|
|
71
|
-
it( 'falls back to provider.imageModel', () => {
|
|
72
|
-
const provider = vi.fn();
|
|
73
|
-
provider.imageModel = vi.fn( model => ( { type: 'legacy-image-model', model } ) );
|
|
74
|
-
getProvider.mockReturnValue( provider );
|
|
75
|
-
|
|
76
|
-
const result = loadImageModel( {
|
|
77
|
-
config: {
|
|
78
|
-
provider: 'custom',
|
|
79
|
-
model: 'image-v1'
|
|
80
|
-
}
|
|
81
|
-
} );
|
|
82
|
-
|
|
83
|
-
expect( provider.imageModel ).toHaveBeenCalledWith( 'image-v1' );
|
|
84
|
-
expect( result ).toEqual( {
|
|
85
|
-
type: 'legacy-image-model',
|
|
86
|
-
model: 'image-v1'
|
|
87
|
-
} );
|
|
88
|
-
} );
|
|
89
|
-
|
|
90
|
-
it( 'prefers provider.image when both image factories exist', () => {
|
|
91
|
-
const provider = vi.fn();
|
|
92
|
-
provider.image = vi.fn( model => ( { type: 'image', model } ) );
|
|
93
|
-
provider.imageModel = vi.fn( model => ( { type: 'imageModel', model } ) );
|
|
94
|
-
getProvider.mockReturnValue( provider );
|
|
95
|
-
|
|
96
|
-
const result = loadImageModel( {
|
|
97
|
-
config: {
|
|
98
|
-
provider: 'custom',
|
|
99
|
-
model: 'image-v1'
|
|
100
|
-
}
|
|
101
|
-
} );
|
|
102
|
-
|
|
103
|
-
expect( provider.image ).toHaveBeenCalledWith( 'image-v1' );
|
|
104
|
-
expect( provider.imageModel ).not.toHaveBeenCalled();
|
|
105
|
-
expect( result.type ).toBe( 'image' );
|
|
106
|
-
} );
|
|
107
|
-
|
|
108
|
-
it( 'throws a clear error when the provider does not support image models', () => {
|
|
109
|
-
getProvider.mockReturnValue( vi.fn() );
|
|
110
|
-
|
|
111
|
-
expect( () => loadImageModel( {
|
|
112
|
-
name: 'foo',
|
|
113
|
-
config: {
|
|
114
|
-
provider: 'azure',
|
|
115
|
-
model: 'gpt-image-1'
|
|
116
|
-
}
|
|
117
|
-
} ) ).toThrow( 'Provider "azure" used by prompt "foo" does not support image models.' );
|
|
118
|
-
} );
|
|
119
|
-
} );
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { fetchModelsPricing, cache } from './models_pricing.js';
|
|
3
|
-
import fixture from '../fixtures/models_api_light.json' with { type: 'json' };
|
|
4
|
-
|
|
5
|
-
const fetchMock = vi.hoisted( () => vi.fn() );
|
|
6
|
-
const EnvHttpProxyAgentMock = vi.hoisted( () => vi.fn( function EnvHttpProxyAgent( options ) {
|
|
7
|
-
this.options = options;
|
|
8
|
-
} ) );
|
|
9
|
-
|
|
10
|
-
vi.mock( 'undici', () => ( {
|
|
11
|
-
EnvHttpProxyAgent: EnvHttpProxyAgentMock,
|
|
12
|
-
fetch: fetchMock
|
|
13
|
-
} ) );
|
|
14
|
-
|
|
15
|
-
const costTableUrl = 'https://models.dev/api.json';
|
|
16
|
-
const okResponse = data => ( {
|
|
17
|
-
ok: true,
|
|
18
|
-
json: () => Promise.resolve( data )
|
|
19
|
-
} );
|
|
20
|
-
const stubFetch = response => {
|
|
21
|
-
fetchMock.mockResolvedValueOnce( response );
|
|
22
|
-
return fetchMock;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
describe( 'modelsPricing', () => {
|
|
26
|
-
beforeEach( () => {
|
|
27
|
-
cache.content = null;
|
|
28
|
-
cache.expiresAt = 0;
|
|
29
|
-
fetchMock.mockReset();
|
|
30
|
-
} );
|
|
31
|
-
|
|
32
|
-
it( 'returns a Map of model costs when fetch succeeds', async () => {
|
|
33
|
-
const fetchMock = stubFetch( okResponse( fixture ) );
|
|
34
|
-
|
|
35
|
-
const result = await fetchModelsPricing();
|
|
36
|
-
|
|
37
|
-
expect( EnvHttpProxyAgentMock ).toHaveBeenCalledWith( { allowH2: false } );
|
|
38
|
-
expect( fetchMock ).toHaveBeenCalledWith( costTableUrl, { dispatcher: EnvHttpProxyAgentMock.mock.results[0].value } );
|
|
39
|
-
expect( result ).toBeInstanceOf( Map );
|
|
40
|
-
expect( result.size ).toBeGreaterThan( 0 );
|
|
41
|
-
const firstModel = Object.values( fixture )[0];
|
|
42
|
-
const firstModelId = Object.keys( firstModel.models )[0];
|
|
43
|
-
const cost = firstModel.models[firstModelId].cost;
|
|
44
|
-
expect( result.get( firstModelId ) ).toBeUndefined();
|
|
45
|
-
expect( result.get( `${firstModel.id}/${firstModelId}` ) ).toEqual( cost );
|
|
46
|
-
} );
|
|
47
|
-
|
|
48
|
-
it( 'includes main providers from fixture (openai, anthropic, google, nvidia, perplexity)', async () => {
|
|
49
|
-
stubFetch( okResponse( fixture ) );
|
|
50
|
-
|
|
51
|
-
const result = await fetchModelsPricing();
|
|
52
|
-
|
|
53
|
-
const openaiProvider = fixture.openai;
|
|
54
|
-
const openaiModelId = Object.keys( openaiProvider.models )[0];
|
|
55
|
-
expect( result.get( openaiModelId ) ).toBeUndefined();
|
|
56
|
-
expect( result.get( `openai/${openaiModelId}` ) ).toEqual( openaiProvider.models[openaiModelId].cost );
|
|
57
|
-
|
|
58
|
-
const anthropicModelId = Object.keys( fixture.anthropic.models )[0];
|
|
59
|
-
expect( result.get( `anthropic/${anthropicModelId}` ) ).toEqual( fixture.anthropic.models[anthropicModelId].cost );
|
|
60
|
-
} );
|
|
61
|
-
|
|
62
|
-
it( 'keeps separate costs when the same model id exists under two providers', async () => {
|
|
63
|
-
stubFetch( okResponse( fixture ) );
|
|
64
|
-
|
|
65
|
-
const result = await fetchModelsPricing();
|
|
66
|
-
const sharedModelId = 'gpt-4o-2024-11-20';
|
|
67
|
-
|
|
68
|
-
expect( result.get( sharedModelId ) ).toBeUndefined();
|
|
69
|
-
expect( result.get( `openai/${sharedModelId}` ) ).toEqual( fixture.openai.models[sharedModelId].cost );
|
|
70
|
-
expect( result.get( `azure/${sharedModelId}` ) ).toEqual( fixture.azure.models[sharedModelId].cost );
|
|
71
|
-
expect( result.get( `openai/${sharedModelId}` ) ).not.toEqual( result.get( `azure/${sharedModelId}` ) );
|
|
72
|
-
} );
|
|
73
|
-
|
|
74
|
-
it( 'returns null when response is not ok and no cache', async () => {
|
|
75
|
-
const status = 500;
|
|
76
|
-
stubFetch( { ok: false, status } );
|
|
77
|
-
|
|
78
|
-
const result = await fetchModelsPricing();
|
|
79
|
-
|
|
80
|
-
expect( result ).toBeNull();
|
|
81
|
-
} );
|
|
82
|
-
|
|
83
|
-
it( 'returns stale cache when response is not ok but cache exists', async () => {
|
|
84
|
-
stubFetch( okResponse( fixture ) );
|
|
85
|
-
await fetchModelsPricing();
|
|
86
|
-
cache.expiresAt = 0; // force refetch so we hit the !res.ok path
|
|
87
|
-
|
|
88
|
-
const status = 404;
|
|
89
|
-
stubFetch( { ok: false, status } );
|
|
90
|
-
|
|
91
|
-
const result = await fetchModelsPricing();
|
|
92
|
-
|
|
93
|
-
expect( result ).toBeInstanceOf( Map );
|
|
94
|
-
expect( result.size ).toBeGreaterThan( 0 );
|
|
95
|
-
} );
|
|
96
|
-
|
|
97
|
-
it( 'returns null when fetch rejects and no cache', async () => {
|
|
98
|
-
const error = new Error( 'network failure' );
|
|
99
|
-
fetchMock.mockRejectedValueOnce( error );
|
|
100
|
-
|
|
101
|
-
const result = await fetchModelsPricing();
|
|
102
|
-
|
|
103
|
-
expect( result ).toBeNull();
|
|
104
|
-
} );
|
|
105
|
-
|
|
106
|
-
it( 'returns stale cache when fetch rejects but cache exists', async () => {
|
|
107
|
-
stubFetch( okResponse( fixture ) );
|
|
108
|
-
const staleCache = await fetchModelsPricing();
|
|
109
|
-
cache.expiresAt = 0; // force refetch so we hit the catch path
|
|
110
|
-
|
|
111
|
-
const error = Object.assign( new Error( 'socket closed' ), { code: 'UND_ERR_SOCKET' } );
|
|
112
|
-
fetchMock.mockRejectedValueOnce( error );
|
|
113
|
-
|
|
114
|
-
const result = await fetchModelsPricing();
|
|
115
|
-
|
|
116
|
-
expect( result ).toBe( staleCache );
|
|
117
|
-
} );
|
|
118
|
-
|
|
119
|
-
it( 'returns stale cache when response JSON parsing fails but cache exists', async () => {
|
|
120
|
-
stubFetch( okResponse( fixture ) );
|
|
121
|
-
const staleCache = await fetchModelsPricing();
|
|
122
|
-
cache.expiresAt = 0; // force refetch so parsing errors can fall back to cache
|
|
123
|
-
|
|
124
|
-
stubFetch( {
|
|
125
|
-
ok: true,
|
|
126
|
-
json: () => Promise.reject( new SyntaxError( 'Unexpected token' ) )
|
|
127
|
-
} );
|
|
128
|
-
|
|
129
|
-
const result = await fetchModelsPricing();
|
|
130
|
-
|
|
131
|
-
expect( result ).toBe( staleCache );
|
|
132
|
-
} );
|
|
133
|
-
|
|
134
|
-
it( 'returns cached Map when cache is still valid', async () => {
|
|
135
|
-
const fetchMock = stubFetch( okResponse( fixture ) );
|
|
136
|
-
|
|
137
|
-
const first = await fetchModelsPricing();
|
|
138
|
-
const second = await fetchModelsPricing();
|
|
139
|
-
|
|
140
|
-
expect( first ).toBe( second );
|
|
141
|
-
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
|
|
142
|
-
} );
|
|
143
|
-
|
|
144
|
-
it( 'only stores models that have a cost object', async () => {
|
|
145
|
-
const dataWithMissingCost = {
|
|
146
|
-
p1: {
|
|
147
|
-
id: 'p1',
|
|
148
|
-
models: {
|
|
149
|
-
withCost: { cost: { input: 1, output: 2 } },
|
|
150
|
-
noCost: { name: 'x' }
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
stubFetch( okResponse( dataWithMissingCost ) );
|
|
155
|
-
|
|
156
|
-
const result = await fetchModelsPricing();
|
|
157
|
-
|
|
158
|
-
expect( result.get( 'p1/withCost' ) ).toEqual( { input: 1, output: 2 } );
|
|
159
|
-
expect( result.get( 'withCost' ) ).toBeUndefined();
|
|
160
|
-
expect( result.get( 'p1/noCost' ) ).toBeUndefined();
|
|
161
|
-
} );
|
|
162
|
-
} );
|
package/src/utils/skills.spec.js
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
-
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { ValidationError } from '@outputai/core';
|
|
6
|
-
import { loadSkills, recursiveLoadSkillFile } from './skills.js';
|
|
7
|
-
|
|
8
|
-
const dirs = [];
|
|
9
|
-
|
|
10
|
-
const tempDir = () => {
|
|
11
|
-
const dir = mkdtempSync( join( tmpdir(), 'skills-' ) );
|
|
12
|
-
dirs.push( dir );
|
|
13
|
-
return dir;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const writeSkill = ( dir, filename, body ) => {
|
|
17
|
-
writeFileSync( join( dir, filename ), body );
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
afterEach( () => {
|
|
21
|
-
for ( const dir of dirs.splice( 0 ) ) {
|
|
22
|
-
rmSync( dir, { recursive: true, force: true } );
|
|
23
|
-
}
|
|
24
|
-
} );
|
|
25
|
-
|
|
26
|
-
describe( 'recursiveLoadSkillFile', () => {
|
|
27
|
-
it( 'returns an empty array for no paths', () => {
|
|
28
|
-
expect( recursiveLoadSkillFile( [] ) ).toEqual( [] );
|
|
29
|
-
} );
|
|
30
|
-
|
|
31
|
-
it( 'throws ValidationError when a path is missing', () => {
|
|
32
|
-
const missing = join( tempDir(), 'gone.md' );
|
|
33
|
-
|
|
34
|
-
expect( () => recursiveLoadSkillFile( [ missing ] ) ).toThrow( ValidationError );
|
|
35
|
-
expect( () => recursiveLoadSkillFile( [ missing ] ) ).toThrow( `Skill path "${missing}" not found` );
|
|
36
|
-
} );
|
|
37
|
-
|
|
38
|
-
it( 'loads an explicitly listed non-md file', () => {
|
|
39
|
-
const dir = tempDir();
|
|
40
|
-
writeSkill( dir, 'notes.txt', 'not a skill' );
|
|
41
|
-
|
|
42
|
-
expect( recursiveLoadSkillFile( [ join( dir, 'notes.txt' ) ] ) ).toEqual( [
|
|
43
|
-
{ name: 'notes.txt', description: 'notes.txt', instructions: 'not a skill' }
|
|
44
|
-
] );
|
|
45
|
-
} );
|
|
46
|
-
|
|
47
|
-
it( 'loads a markdown file and defaults name/description to the basename', () => {
|
|
48
|
-
const dir = tempDir();
|
|
49
|
-
writeSkill( dir, 'writer.md', '# Writer\n\nDo the thing.\n' );
|
|
50
|
-
|
|
51
|
-
expect( recursiveLoadSkillFile( [ join( dir, 'writer.md' ) ] ) ).toEqual( [ {
|
|
52
|
-
name: 'writer',
|
|
53
|
-
description: 'writer',
|
|
54
|
-
instructions: '# Writer\n\nDo the thing.'
|
|
55
|
-
} ] );
|
|
56
|
-
} );
|
|
57
|
-
|
|
58
|
-
it( 'uses frontmatter name and description when present', () => {
|
|
59
|
-
const dir = tempDir();
|
|
60
|
-
writeSkill( dir, 'file.md', [
|
|
61
|
-
'---',
|
|
62
|
-
'name: copywriter',
|
|
63
|
-
'description: Writes marketing copy',
|
|
64
|
-
'---',
|
|
65
|
-
'',
|
|
66
|
-
'Use a confident tone.'
|
|
67
|
-
].join( '\n' ) );
|
|
68
|
-
|
|
69
|
-
expect( recursiveLoadSkillFile( [ join( dir, 'file.md' ) ] ) ).toEqual( [ {
|
|
70
|
-
name: 'copywriter',
|
|
71
|
-
description: 'Writes marketing copy',
|
|
72
|
-
instructions: 'Use a confident tone.'
|
|
73
|
-
} ] );
|
|
74
|
-
} );
|
|
75
|
-
|
|
76
|
-
it( 'returns an empty array for an empty directory', () => {
|
|
77
|
-
expect( recursiveLoadSkillFile( [ tempDir() ] ) ).toEqual( [] );
|
|
78
|
-
} );
|
|
79
|
-
|
|
80
|
-
it( 'loads markdown files from a directory in sorted order and skips other files', () => {
|
|
81
|
-
const dir = tempDir();
|
|
82
|
-
writeSkill( dir, 'z.md', 'zeta' );
|
|
83
|
-
writeSkill( dir, 'a.md', 'alpha' );
|
|
84
|
-
writeSkill( dir, 'readme.txt', 'ignore' );
|
|
85
|
-
|
|
86
|
-
expect( recursiveLoadSkillFile( [ dir ] ) ).toEqual( [
|
|
87
|
-
{ name: 'a', description: 'a', instructions: 'alpha' },
|
|
88
|
-
{ name: 'z', description: 'z', instructions: 'zeta' }
|
|
89
|
-
] );
|
|
90
|
-
} );
|
|
91
|
-
|
|
92
|
-
it( 'recurses into nested directories', () => {
|
|
93
|
-
const dir = tempDir();
|
|
94
|
-
const nested = join( dir, 'nested' );
|
|
95
|
-
mkdirSync( nested );
|
|
96
|
-
writeSkill( dir, 'root.md', 'root' );
|
|
97
|
-
writeSkill( nested, 'child.md', 'child' );
|
|
98
|
-
|
|
99
|
-
expect( recursiveLoadSkillFile( [ dir ] ) ).toEqual( [
|
|
100
|
-
{ name: 'child', description: 'child', instructions: 'child' },
|
|
101
|
-
{ name: 'root', description: 'root', instructions: 'root' }
|
|
102
|
-
] );
|
|
103
|
-
} );
|
|
104
|
-
|
|
105
|
-
it( 'loads files from a directory whose name ends in .md', () => {
|
|
106
|
-
const dir = tempDir();
|
|
107
|
-
const nested = join( dir, 'nested.md' );
|
|
108
|
-
mkdirSync( nested );
|
|
109
|
-
writeSkill( nested, 'child.md', 'child' );
|
|
110
|
-
|
|
111
|
-
expect( recursiveLoadSkillFile( [ nested ] ) ).toEqual( [
|
|
112
|
-
{ name: 'child', description: 'child', instructions: 'child' }
|
|
113
|
-
] );
|
|
114
|
-
} );
|
|
115
|
-
|
|
116
|
-
it( 'does not follow directory symlinks', () => {
|
|
117
|
-
const dir = tempDir();
|
|
118
|
-
const target = tempDir();
|
|
119
|
-
writeSkill( target, 'external.md', 'external' );
|
|
120
|
-
symlinkSync( target, join( dir, 'linked' ), 'dir' );
|
|
121
|
-
|
|
122
|
-
expect( recursiveLoadSkillFile( [ dir ] ) ).toEqual( [] );
|
|
123
|
-
} );
|
|
124
|
-
} );
|
|
125
|
-
|
|
126
|
-
describe( 'loadSkills', () => {
|
|
127
|
-
it( 'returns an empty array when the prompt lists no skills', () => {
|
|
128
|
-
expect( loadSkills( { fileDir: tempDir(), config: { skills: [] } } ) ).toEqual( [] );
|
|
129
|
-
} );
|
|
130
|
-
|
|
131
|
-
it( 'resolves skill paths relative to the prompt fileDir', () => {
|
|
132
|
-
const fileDir = tempDir();
|
|
133
|
-
const skillsDir = join( fileDir, 'skills' );
|
|
134
|
-
mkdirSync( skillsDir );
|
|
135
|
-
writeSkill( skillsDir, 'reviewer.md', [
|
|
136
|
-
'---',
|
|
137
|
-
'name: reviewer',
|
|
138
|
-
'description: Reviews drafts',
|
|
139
|
-
'---',
|
|
140
|
-
'Check the brief.'
|
|
141
|
-
].join( '\n' ) );
|
|
142
|
-
|
|
143
|
-
expect( loadSkills( {
|
|
144
|
-
fileDir,
|
|
145
|
-
config: { skills: [ 'skills/reviewer.md' ] }
|
|
146
|
-
} ) ).toEqual( [ {
|
|
147
|
-
name: 'reviewer',
|
|
148
|
-
description: 'Reviews drafts',
|
|
149
|
-
instructions: 'Check the brief.'
|
|
150
|
-
} ] );
|
|
151
|
-
} );
|
|
152
|
-
|
|
153
|
-
it( 'loads a directory listed in prompt config', () => {
|
|
154
|
-
const fileDir = tempDir();
|
|
155
|
-
const skillsDir = join( fileDir, 'skills' );
|
|
156
|
-
mkdirSync( skillsDir );
|
|
157
|
-
writeSkill( skillsDir, 'one.md', 'first' );
|
|
158
|
-
|
|
159
|
-
expect( loadSkills( {
|
|
160
|
-
fileDir,
|
|
161
|
-
config: { skills: [ 'skills' ] }
|
|
162
|
-
} ) ).toEqual( [ {
|
|
163
|
-
name: 'one',
|
|
164
|
-
description: 'one',
|
|
165
|
-
instructions: 'first'
|
|
166
|
-
} ] );
|
|
167
|
-
} );
|
|
168
|
-
} );
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import { describe, it, expect } from 'vitest';
|
|
3
|
-
import { extractSources } from './sources.js';
|
|
4
|
-
|
|
5
|
-
const hashedId = url => createHash( 'sha256' ).update( url ).digest( 'hex' ).slice( 0, 16 );
|
|
6
|
-
|
|
7
|
-
const fromSteps = ( steps, sources = [] ) => extractSources( { steps, sources } );
|
|
8
|
-
|
|
9
|
-
const searchStep = results => ( {
|
|
10
|
-
toolResults: [ { output: { results } } ]
|
|
11
|
-
} );
|
|
12
|
-
|
|
13
|
-
describe( 'extractSources', () => {
|
|
14
|
-
it( 'returns empty when steps and sources are missing or empty', () => {
|
|
15
|
-
expect( extractSources( {} ) ).toEqual( [] );
|
|
16
|
-
expect( fromSteps( undefined ) ).toEqual( [] );
|
|
17
|
-
expect( fromSteps( null ) ).toEqual( [] );
|
|
18
|
-
expect( fromSteps( [] ) ).toEqual( [] );
|
|
19
|
-
expect( fromSteps( [ { text: 'hello' } ] ) ).toEqual( [] );
|
|
20
|
-
} );
|
|
21
|
-
|
|
22
|
-
it( 'treats non-array response sources as empty', () => {
|
|
23
|
-
const steps = [ searchStep( [ { url: 'https://u.test', title: 'T' } ] ) ];
|
|
24
|
-
expect( extractSources( { steps, sources: undefined } ) ).toHaveLength( 1 );
|
|
25
|
-
expect( extractSources( { steps, sources: {} } ) ).toHaveLength( 1 );
|
|
26
|
-
} );
|
|
27
|
-
|
|
28
|
-
it( 'skips tool results that are not a results array with urls', () => {
|
|
29
|
-
const steps = [ {
|
|
30
|
-
toolResults: [
|
|
31
|
-
{ output: { answer: 'some text' } },
|
|
32
|
-
{ output: { results: [] } },
|
|
33
|
-
{ output: { results: [ { title: 'no url' } ] } },
|
|
34
|
-
{ output: { results: [ { url: ' ' } ] } },
|
|
35
|
-
{ output: 'plain string' }
|
|
36
|
-
]
|
|
37
|
-
} ];
|
|
38
|
-
expect( fromSteps( steps ) ).toEqual( [] );
|
|
39
|
-
} );
|
|
40
|
-
|
|
41
|
-
it( 'extracts url and title from search-shaped results', () => {
|
|
42
|
-
const sources = fromSteps( [ searchStep( [
|
|
43
|
-
{ url: 'https://example.com/1', title: 'Example 1', snippet: 'text' },
|
|
44
|
-
{ url: 'https://example.com/2', title: 'Example 2' }
|
|
45
|
-
] ) ] );
|
|
46
|
-
|
|
47
|
-
expect( sources ).toHaveLength( 2 );
|
|
48
|
-
expect( sources[0] ).toEqual( {
|
|
49
|
-
type: 'source',
|
|
50
|
-
sourceType: 'url',
|
|
51
|
-
id: hashedId( 'https://example.com/1' ),
|
|
52
|
-
url: 'https://example.com/1',
|
|
53
|
-
title: 'Example 1'
|
|
54
|
-
} );
|
|
55
|
-
expect( sources[1].url ).toBe( 'https://example.com/2' );
|
|
56
|
-
} );
|
|
57
|
-
|
|
58
|
-
it( 'keeps the last tool hit when urls repeat', () => {
|
|
59
|
-
const steps = [
|
|
60
|
-
searchStep( [ { url: 'https://dup.com', title: 'First' } ] ),
|
|
61
|
-
searchStep( [ { url: 'https://dup.com', title: 'Second' } ] )
|
|
62
|
-
];
|
|
63
|
-
const sources = fromSteps( steps );
|
|
64
|
-
expect( sources ).toHaveLength( 1 );
|
|
65
|
-
expect( sources[0].title ).toBe( 'Second' );
|
|
66
|
-
} );
|
|
67
|
-
|
|
68
|
-
it( 'keeps a search hit among mixed tool results', () => {
|
|
69
|
-
const steps = [ {
|
|
70
|
-
toolResults: [
|
|
71
|
-
{ output: { calculation: 42 } },
|
|
72
|
-
{ output: { results: [ { url: 'https://real.com', title: 'Real' } ] } },
|
|
73
|
-
{ output: 'plain string' }
|
|
74
|
-
]
|
|
75
|
-
} ];
|
|
76
|
-
const sources = fromSteps( steps );
|
|
77
|
-
expect( sources ).toHaveLength( 1 );
|
|
78
|
-
expect( sources[0].url ).toBe( 'https://real.com' );
|
|
79
|
-
} );
|
|
80
|
-
|
|
81
|
-
it( 'defaults title to empty string when missing', () => {
|
|
82
|
-
expect( fromSteps( [ searchStep( [ { url: 'https://notitle.com' } ] ) ] )[0].title ).toBe( '' );
|
|
83
|
-
} );
|
|
84
|
-
|
|
85
|
-
it( 'trims urls for storage and id', () => {
|
|
86
|
-
const sources = fromSteps( [ searchStep( [ { url: ' https://stable.com/path ', title: 'S' } ] ) ] );
|
|
87
|
-
expect( sources[0].url ).toBe( 'https://stable.com/path' );
|
|
88
|
-
expect( sources[0].id ).toBe( hashedId( 'https://stable.com/path' ) );
|
|
89
|
-
expect( sources[0].id ).toHaveLength( 16 );
|
|
90
|
-
} );
|
|
91
|
-
|
|
92
|
-
it( 'lets response sources win on the same url', () => {
|
|
93
|
-
const url = 'https://shared.test';
|
|
94
|
-
const sources = extractSources( {
|
|
95
|
-
steps: [ searchStep( [ { url, title: 'from-tool' } ] ) ],
|
|
96
|
-
sources: [ { url, title: 'from-response', type: 'source', sourceType: 'url', id: 'b' } ]
|
|
97
|
-
} );
|
|
98
|
-
|
|
99
|
-
expect( sources ).toHaveLength( 1 );
|
|
100
|
-
expect( sources[0].title ).toBe( 'from-response' );
|
|
101
|
-
} );
|
|
102
|
-
|
|
103
|
-
it( 'keeps document sources that have id and no url', () => {
|
|
104
|
-
const docA = { type: 'source', sourceType: 'document', id: 'doc-a', mediaType: 'application/pdf', title: 'A' };
|
|
105
|
-
const docB = { type: 'source', sourceType: 'document', id: 'doc-b', mediaType: 'application/pdf', title: 'B' };
|
|
106
|
-
|
|
107
|
-
expect( extractSources( { sources: [ docA, docB ] } ) ).toEqual( [ docA, docB ] );
|
|
108
|
-
} );
|
|
109
|
-
|
|
110
|
-
it( 'keeps url and document sources together', () => {
|
|
111
|
-
const url = 'https://page.test';
|
|
112
|
-
const doc = { type: 'source', sourceType: 'document', id: 'doc-1', title: 'Doc' };
|
|
113
|
-
const sources = extractSources( {
|
|
114
|
-
steps: [ searchStep( [ { url, title: 'from-tool' } ] ) ],
|
|
115
|
-
sources: [ doc ]
|
|
116
|
-
} );
|
|
117
|
-
|
|
118
|
-
expect( sources ).toHaveLength( 2 );
|
|
119
|
-
expect( sources[0].url ).toBe( url );
|
|
120
|
-
expect( sources[1] ).toBe( doc );
|
|
121
|
-
} );
|
|
122
|
-
} );
|
package/src/utils/stream.spec.js
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { drainStream } from './stream.js';
|
|
3
|
-
|
|
4
|
-
const asyncParts = parts => ( {
|
|
5
|
-
async *[Symbol.asyncIterator]() {
|
|
6
|
-
yield* parts;
|
|
7
|
-
}
|
|
8
|
-
} );
|
|
9
|
-
|
|
10
|
-
const streamOf = parts => ( { stream: asyncParts( parts ) } );
|
|
11
|
-
|
|
12
|
-
describe( 'drainStream', () => {
|
|
13
|
-
it( 'consumes stream parts without throwing', async () => {
|
|
14
|
-
await expect( drainStream( streamOf( [ { type: 'text-delta', text: 'hi' } ] ) ) ).resolves.toBeUndefined();
|
|
15
|
-
} );
|
|
16
|
-
|
|
17
|
-
it( 'throws the abort signal reason when it is an Error', async () => {
|
|
18
|
-
const abortController = new AbortController();
|
|
19
|
-
const abortReason = new Error( 'Cancelled by caller' );
|
|
20
|
-
abortController.abort( abortReason );
|
|
21
|
-
|
|
22
|
-
await expect( drainStream(
|
|
23
|
-
streamOf( [ { type: 'abort', reason: abortReason.message } ] ),
|
|
24
|
-
abortController.signal
|
|
25
|
-
) ).rejects.toBe( abortReason );
|
|
26
|
-
} );
|
|
27
|
-
|
|
28
|
-
it( 'throws part.reason when the abort signal reason is not an Error', async () => {
|
|
29
|
-
const abortController = new AbortController();
|
|
30
|
-
abortController.abort( 'cancelled' );
|
|
31
|
-
|
|
32
|
-
await expect( drainStream(
|
|
33
|
-
streamOf( [ { type: 'abort', reason: 'from-part' } ] ),
|
|
34
|
-
abortController.signal
|
|
35
|
-
) ).rejects.toMatchObject( { message: 'from-part', cause: 'cancelled' } );
|
|
36
|
-
} );
|
|
37
|
-
|
|
38
|
-
it( 'throws a generic abort error when no reason is available', async () => {
|
|
39
|
-
await expect( drainStream( streamOf( [ { type: 'abort' } ] ) ) ).rejects.toThrow( 'Streaming generation aborted.' );
|
|
40
|
-
} );
|
|
41
|
-
|
|
42
|
-
it( 'throws the provider error when the error part is an Error', async () => {
|
|
43
|
-
const error = new Error( 'Provider failed' );
|
|
44
|
-
|
|
45
|
-
await expect( drainStream( streamOf( [ { type: 'error', error } ] ) ) ).rejects.toBe( error );
|
|
46
|
-
} );
|
|
47
|
-
|
|
48
|
-
it( 'wraps a non-Error error part', async () => {
|
|
49
|
-
await expect( drainStream( streamOf( [ { type: 'error', error: 'no model' } ] ) ) ).rejects.toThrow( 'no model' );
|
|
50
|
-
} );
|
|
51
|
-
|
|
52
|
-
it( 'throws a generic stream error when the error part has no value', async () => {
|
|
53
|
-
await expect( drainStream( streamOf( [ { type: 'error', error: null } ] ) ) ).rejects.toThrow( 'Streaming generation failed.' );
|
|
54
|
-
} );
|
|
55
|
-
} );
|