@outputai/llm 0.7.0 → 0.7.1-next.2a4105c.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 +2 -2
- package/src/ai_sdk_options.js +2 -1
- package/src/ai_sdk_options.spec.js +28 -0
- package/src/cost/index.js +7 -2
- package/src/cost/index.spec.js +5 -2
- package/src/index.d.ts +13 -0
- package/src/prompt/block_options.js +58 -0
- package/src/prompt/block_options.spec.js +71 -0
- package/src/prompt/blocks.js +47 -0
- package/src/prompt/blocks.spec.js +63 -0
- package/src/prompt/parser.js +2 -5
- package/src/prompt/parser.spec.js +19 -0
- package/src/prompt/validations.js +8 -2
- package/src/prompt/validations.spec.js +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@outputai/llm",
|
|
3
|
-
"version": "0.7.0",
|
|
3
|
+
"version": "0.7.1-next.2a4105c.0",
|
|
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.1.0",
|
|
16
|
-
"@outputai/core": "0.7.0"
|
|
16
|
+
"@outputai/core": "0.7.1-next.2a4105c.0"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"ai": "6.0.168",
|
package/src/ai_sdk_options.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { loadImageModel, loadTextModel, loadTools } from './ai_model.js';
|
|
2
|
+
import { resolveMessageProviderOptions } from './prompt/block_options.js';
|
|
2
3
|
import { FatalError } from '@outputai/core';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -13,7 +14,7 @@ export const loadAiSdkTextOptions = prompt => {
|
|
|
13
14
|
}
|
|
14
15
|
const options = {
|
|
15
16
|
model: loadTextModel( prompt ),
|
|
16
|
-
messages: prompt
|
|
17
|
+
messages: resolveMessageProviderOptions( prompt ),
|
|
17
18
|
providerOptions: prompt.config.providerOptions
|
|
18
19
|
};
|
|
19
20
|
|
|
@@ -161,4 +161,32 @@ describe( 'ai_sdk_options', () => {
|
|
|
161
161
|
);
|
|
162
162
|
expect( loadImageModelImpl ).not.toHaveBeenCalled();
|
|
163
163
|
} );
|
|
164
|
+
|
|
165
|
+
it( 'resolves block attributes into per-message providerOptions', async () => {
|
|
166
|
+
const prompt = {
|
|
167
|
+
name: 'cache@v1',
|
|
168
|
+
config: {
|
|
169
|
+
provider: 'anthropic',
|
|
170
|
+
model: 'claude-sonnet-4-5',
|
|
171
|
+
messageOptions: { cached: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } } }
|
|
172
|
+
},
|
|
173
|
+
messages: [
|
|
174
|
+
{ role: 'system', content: 'Static', attributes: { options: 'cached' } },
|
|
175
|
+
{ role: 'user', content: 'Hello' }
|
|
176
|
+
],
|
|
177
|
+
instructions: null
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const { loadAiSdkTextOptions } = await importSut();
|
|
181
|
+
const result = loadAiSdkTextOptions( prompt );
|
|
182
|
+
|
|
183
|
+
expect( result.messages ).toEqual( [
|
|
184
|
+
{
|
|
185
|
+
role: 'system',
|
|
186
|
+
content: 'Static',
|
|
187
|
+
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } }
|
|
188
|
+
},
|
|
189
|
+
{ role: 'user', content: 'Hello' }
|
|
190
|
+
] );
|
|
191
|
+
} );
|
|
164
192
|
} );
|
package/src/cost/index.js
CHANGED
|
@@ -32,8 +32,13 @@ export const calculateLLMCallCost = async ( { modelId, usage } ) => {
|
|
|
32
32
|
if ( Number.isFinite( pricing.input ) && Number.isFinite( nonCachedTokens ) ) {
|
|
33
33
|
llmUsage.addUsage( { type: 'input', ppm: pricing.input, amount: nonCachedTokens } );
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Surface cached input tokens whenever the provider reports them, even if the model's
|
|
36
|
+
// pricing lacks a cache_read rate — otherwise caching savings vanish from the token
|
|
37
|
+
// aggregation (these tokens are already excluded from the input line above). Price at
|
|
38
|
+
// cache_read when available, otherwise at 0.
|
|
39
|
+
if ( Number.isFinite( cachedInputTokens ) ) {
|
|
40
|
+
const cacheReadPpm = Number.isFinite( pricing.cache_read ) ? pricing.cache_read : 0;
|
|
41
|
+
llmUsage.addUsage( { type: 'input_cached', ppm: cacheReadPpm, amount: cachedInputTokens } );
|
|
37
42
|
}
|
|
38
43
|
if ( Number.isFinite( pricing.output ) && Number.isFinite( outputTokens ) ) {
|
|
39
44
|
llmUsage.addUsage( { type: 'output', ppm: pricing.output, amount: outputTokens } );
|
package/src/cost/index.spec.js
CHANGED
|
@@ -132,7 +132,7 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
132
132
|
} );
|
|
133
133
|
} );
|
|
134
134
|
|
|
135
|
-
it( '
|
|
135
|
+
it( 'still counts cached tokens when the model has no cache_read rate', async () => {
|
|
136
136
|
mockFetchModelsPricing.mockResolvedValue( new Map( [ [ 'no-cache', { input: 2, output: 10 } ] ] ) );
|
|
137
137
|
|
|
138
138
|
const result = await calculateLLMCallCost( {
|
|
@@ -140,14 +140,17 @@ describe( 'calculateLLMCallCost', () => {
|
|
|
140
140
|
usage: { inputTokens: 1_000_000, cachedInputTokens: 200_000, outputTokens: 0 }
|
|
141
141
|
} );
|
|
142
142
|
|
|
143
|
+
// Cached tokens are surfaced (priced at 0 without a cache_read rate) so caching is
|
|
144
|
+
// visible in the aggregation; cost is unchanged since they are excluded from `input`.
|
|
143
145
|
expectLLMUsage( result, {
|
|
144
146
|
modelId: 'no-cache',
|
|
145
147
|
usage: [
|
|
146
148
|
{ type: 'input', ppm: 2, amount: 800_000, total: 1.6 },
|
|
149
|
+
{ type: 'input_cached', ppm: 0, amount: 200_000, total: 0 },
|
|
147
150
|
{ type: 'output', ppm: 10, amount: 0, total: 0 }
|
|
148
151
|
],
|
|
149
152
|
total: 1.6,
|
|
150
|
-
tokensUsed:
|
|
153
|
+
tokensUsed: 1_000_000
|
|
151
154
|
} );
|
|
152
155
|
} );
|
|
153
156
|
|
package/src/index.d.ts
CHANGED
|
@@ -57,6 +57,12 @@ export type PromptMessage = {
|
|
|
57
57
|
role: string;
|
|
58
58
|
/** The content of the message */
|
|
59
59
|
content: string;
|
|
60
|
+
/**
|
|
61
|
+
* Parsed opening-tag attributes for the block. Currently `options` — a space-separated list of
|
|
62
|
+
* frontmatter `messageOptions` set names — which is resolved into per-message `providerOptions`
|
|
63
|
+
* at call time and stripped before the request is sent. Authored as `<system options="set_a set_b">`.
|
|
64
|
+
*/
|
|
65
|
+
attributes?: Record<string, string | boolean>;
|
|
60
66
|
};
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -139,6 +145,13 @@ export type Prompt = {
|
|
|
139
145
|
|
|
140
146
|
/** Provider-specific options */
|
|
141
147
|
providerOptions?: Record<string, unknown>;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Named, reusable per-message `providerOptions` sets, referenced from message blocks via the
|
|
151
|
+
* `options="<name>"` attribute. Each value is a provider-namespaced options object, e.g.
|
|
152
|
+
* `{ anthropic: { cacheControl: { type: 'ephemeral' } } }`.
|
|
153
|
+
*/
|
|
154
|
+
messageOptions?: Record<string, Record<string, Record<string, unknown>>>;
|
|
142
155
|
};
|
|
143
156
|
|
|
144
157
|
/** Array of messages in the conversation */
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { FatalError, z } from '@outputai/core';
|
|
2
|
+
|
|
3
|
+
/** Shallow-merge two providerOptions objects, combining keys within each provider namespace. */
|
|
4
|
+
const mergeProviderOptions = ( base = {}, extra = {} ) => {
|
|
5
|
+
const merged = { ...base };
|
|
6
|
+
for ( const [ namespace, options ] of Object.entries( extra ) ) {
|
|
7
|
+
merged[namespace] = { ...merged[namespace], ...options };
|
|
8
|
+
}
|
|
9
|
+
return merged;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Merge the named `messageOptions` sets referenced by a block's `options` attribute. */
|
|
13
|
+
const resolveOptions = ( value, { name, config } ) => {
|
|
14
|
+
const sets = config.messageOptions ?? {};
|
|
15
|
+
return value.trim().split( /\s+/ ).reduce( ( acc, setName ) => {
|
|
16
|
+
if ( !sets[setName] ) {
|
|
17
|
+
throw new FatalError( `Prompt "${name}" references unknown messageOptions set "${setName}"` );
|
|
18
|
+
}
|
|
19
|
+
return mergeProviderOptions( acc, sets[setName] );
|
|
20
|
+
}, {} );
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Registry of supported block attributes. Each entry declares how the attribute is validated
|
|
25
|
+
* (`schema`) and how it contributes to a message's per-message `providerOptions` (`resolve`).
|
|
26
|
+
* Add an entry to support a new block option — validation ({@link attributesSchema}) and
|
|
27
|
+
* resolution ({@link resolveMessageProviderOptions}) both derive from this table.
|
|
28
|
+
*/
|
|
29
|
+
const BLOCK_OPTIONS = {
|
|
30
|
+
options: {
|
|
31
|
+
schema: z.string().min( 1 ),
|
|
32
|
+
resolve: resolveOptions
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Zod schema for a block's `attributes` object, derived from the option registry. */
|
|
37
|
+
export const attributesSchema = z.object(
|
|
38
|
+
Object.fromEntries(
|
|
39
|
+
Object.entries( BLOCK_OPTIONS ).map( ( [ name, def ] ) => [ name, def.schema.optional() ] )
|
|
40
|
+
)
|
|
41
|
+
).strict();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve each message's authoring `attributes` into AI SDK per-message `providerOptions`,
|
|
45
|
+
* returning clean messages with the `attributes` helper stripped.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} prompt - Loaded prompt object (`{ name, config, messages }`)
|
|
48
|
+
* @returns {Array<object>} Messages with resolved `providerOptions`
|
|
49
|
+
*/
|
|
50
|
+
export const resolveMessageProviderOptions = ( { name, config, messages } ) =>
|
|
51
|
+
messages.map( ( { attributes, providerOptions, ...message } ) => {
|
|
52
|
+
const resolved = Object.entries( attributes ?? {} ).reduce( ( acc, [ key, value ] ) => {
|
|
53
|
+
const option = BLOCK_OPTIONS[key];
|
|
54
|
+
return option ? mergeProviderOptions( acc, option.resolve( value, { name, config } ) ) : acc;
|
|
55
|
+
}, providerOptions ?? {} );
|
|
56
|
+
|
|
57
|
+
return Object.keys( resolved ).length > 0 ? { ...message, providerOptions: resolved } : message;
|
|
58
|
+
} );
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { FatalError } from '@outputai/core';
|
|
3
|
+
import { attributesSchema, resolveMessageProviderOptions } from './block_options.js';
|
|
4
|
+
|
|
5
|
+
const textPrompt = ( { config = {}, messages } ) => ( {
|
|
6
|
+
name: 'test@v1',
|
|
7
|
+
config: { provider: 'anthropic', model: 'claude-sonnet-4-5', ...config },
|
|
8
|
+
messages
|
|
9
|
+
} );
|
|
10
|
+
|
|
11
|
+
describe( 'attributesSchema', () => {
|
|
12
|
+
it( 'accepts the options attribute', () => {
|
|
13
|
+
expect( attributesSchema.safeParse( { options: 'cached' } ).success ).toBe( true );
|
|
14
|
+
expect( attributesSchema.safeParse( { options: 'cached fast' } ).success ).toBe( true );
|
|
15
|
+
expect( attributesSchema.safeParse( {} ).success ).toBe( true );
|
|
16
|
+
} );
|
|
17
|
+
|
|
18
|
+
it( 'rejects unknown attributes, including the removed cache shorthand', () => {
|
|
19
|
+
expect( attributesSchema.safeParse( { cache: true } ).success ).toBe( false );
|
|
20
|
+
expect( attributesSchema.safeParse( { unknown: 'x' } ).success ).toBe( false );
|
|
21
|
+
} );
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
describe( 'resolveMessageProviderOptions', () => {
|
|
25
|
+
it( 'merges a referenced messageOptions set into per-message providerOptions', () => {
|
|
26
|
+
const result = resolveMessageProviderOptions( textPrompt( {
|
|
27
|
+
config: { messageOptions: { cached: { anthropic: { cacheControl: { type: 'ephemeral' } } } } },
|
|
28
|
+
messages: [
|
|
29
|
+
{ role: 'system', content: 'Docs', attributes: { options: 'cached' } },
|
|
30
|
+
{ role: 'user', content: 'Hello' }
|
|
31
|
+
]
|
|
32
|
+
} ) );
|
|
33
|
+
|
|
34
|
+
expect( result ).toEqual( [
|
|
35
|
+
{
|
|
36
|
+
role: 'system',
|
|
37
|
+
content: 'Docs',
|
|
38
|
+
providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
|
39
|
+
},
|
|
40
|
+
{ role: 'user', content: 'Hello' }
|
|
41
|
+
] );
|
|
42
|
+
} );
|
|
43
|
+
|
|
44
|
+
it( 'merges multiple referenced sets onto one block', () => {
|
|
45
|
+
const [ system ] = resolveMessageProviderOptions( textPrompt( {
|
|
46
|
+
config: {
|
|
47
|
+
messageOptions: {
|
|
48
|
+
cached: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } },
|
|
49
|
+
openaiKey: { openai: { promptCacheKey: 'enrich-v1' } }
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
messages: [ { role: 'system', content: 'Docs', attributes: { options: 'cached openaiKey' } } ]
|
|
53
|
+
} ) );
|
|
54
|
+
|
|
55
|
+
expect( system.providerOptions ).toEqual( {
|
|
56
|
+
anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } },
|
|
57
|
+
openai: { promptCacheKey: 'enrich-v1' }
|
|
58
|
+
} );
|
|
59
|
+
} );
|
|
60
|
+
|
|
61
|
+
it( 'throws when the options attribute references an unknown set', () => {
|
|
62
|
+
expect( () => resolveMessageProviderOptions( textPrompt( {
|
|
63
|
+
messages: [ { role: 'user', content: 'Hello', attributes: { options: 'missing' } } ]
|
|
64
|
+
} ) ) ).toThrow( FatalError );
|
|
65
|
+
} );
|
|
66
|
+
|
|
67
|
+
it( 'leaves messages without attributes unchanged', () => {
|
|
68
|
+
const messages = [ { role: 'user', content: 'Hello' } ];
|
|
69
|
+
expect( resolveMessageProviderOptions( textPrompt( { messages } ) ) ).toEqual( messages );
|
|
70
|
+
} );
|
|
71
|
+
} );
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roles that introduce a message block. Add a role here to support a new
|
|
3
|
+
* `<role>...</role>` block — the tokenizer pattern is derived from this set,
|
|
4
|
+
* so no other parser change is required.
|
|
5
|
+
*/
|
|
6
|
+
export const BLOCK_ROLES = new Set( [ 'system', 'user', 'assistant', 'tool' ] );
|
|
7
|
+
|
|
8
|
+
const BLOCK_PATTERN = new RegExp(
|
|
9
|
+
`<(${[ ...BLOCK_ROLES ].join( '|' )})((?:\\s[^>]*)?)>([\\s\\S]*?)<\\/\\1>`,
|
|
10
|
+
'gm'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const ATTRIBUTE_PATTERN = /([a-zA-Z][\w-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse a raw opening-tag attribute string into a plain object. Supports bare booleans
|
|
17
|
+
* (`cache`), double/single-quoted values, and unquoted values:
|
|
18
|
+
* `cache options="a b" ttl='1h'` → `{ cache: true, options: 'a b', ttl: '1h' }`.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} [raw] - Raw attribute text between the role and the closing `>`
|
|
21
|
+
* @returns {Record<string, string | true>} Parsed attributes
|
|
22
|
+
*/
|
|
23
|
+
export const parseAttributes = ( raw = '' ) =>
|
|
24
|
+
Object.fromEntries(
|
|
25
|
+
[ ...raw.matchAll( ATTRIBUTE_PATTERN ) ].map(
|
|
26
|
+
( [ _, key, doubleQuoted, singleQuoted, bare ] ) =>
|
|
27
|
+
[ key, doubleQuoted ?? singleQuoted ?? bare ?? true ]
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Tokenize a rendered prompt body into message blocks. Each block is `{ role, content }`,
|
|
33
|
+
* plus `attributes` when the opening tag carried any. Content between role tags is treated
|
|
34
|
+
* as opaque text, so prompt bodies may freely contain other angle-bracket markup.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} content - Rendered prompt body (after frontmatter is stripped)
|
|
37
|
+
* @returns {Array<{ role: string, content: string, attributes?: Record<string, string | true> }>}
|
|
38
|
+
*/
|
|
39
|
+
export const tokenizeBlocks = content =>
|
|
40
|
+
[ ...content.matchAll( BLOCK_PATTERN ) ].map( ( [ _, role, rawAttributes, text ] ) => {
|
|
41
|
+
const attributes = parseAttributes( rawAttributes.trim() );
|
|
42
|
+
return {
|
|
43
|
+
role,
|
|
44
|
+
content: text.trim(),
|
|
45
|
+
...( Object.keys( attributes ).length > 0 && { attributes } )
|
|
46
|
+
};
|
|
47
|
+
} );
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseAttributes, tokenizeBlocks, BLOCK_ROLES } from './blocks.js';
|
|
3
|
+
|
|
4
|
+
describe( 'parseAttributes', () => {
|
|
5
|
+
it( 'parses a bare attribute as boolean true', () => {
|
|
6
|
+
expect( parseAttributes( 'pinned' ) ).toEqual( { pinned: true } );
|
|
7
|
+
} );
|
|
8
|
+
|
|
9
|
+
it( 'parses double- and single-quoted values', () => {
|
|
10
|
+
expect( parseAttributes( 'ttl="1h" mode=\'fast\'' ) ).toEqual( { ttl: '1h', mode: 'fast' } );
|
|
11
|
+
} );
|
|
12
|
+
|
|
13
|
+
it( 'parses unquoted values', () => {
|
|
14
|
+
expect( parseAttributes( 'ttl=1h' ) ).toEqual( { ttl: '1h' } );
|
|
15
|
+
} );
|
|
16
|
+
|
|
17
|
+
it( 'parses multiple attributes and preserves spaces inside quotes', () => {
|
|
18
|
+
expect( parseAttributes( 'pinned options="cached fast"' ) ).toEqual( {
|
|
19
|
+
pinned: true,
|
|
20
|
+
options: 'cached fast'
|
|
21
|
+
} );
|
|
22
|
+
} );
|
|
23
|
+
|
|
24
|
+
it( 'returns an empty object for blank input', () => {
|
|
25
|
+
expect( parseAttributes( '' ) ).toEqual( {} );
|
|
26
|
+
expect( parseAttributes() ).toEqual( {} );
|
|
27
|
+
} );
|
|
28
|
+
} );
|
|
29
|
+
|
|
30
|
+
describe( 'tokenizeBlocks', () => {
|
|
31
|
+
it( 'tokenizes plain blocks without an attributes key', () => {
|
|
32
|
+
const blocks = tokenizeBlocks( '<system>Hi</system>\n<user>Yo</user>' );
|
|
33
|
+
expect( blocks ).toEqual( [
|
|
34
|
+
{ role: 'system', content: 'Hi' },
|
|
35
|
+
{ role: 'user', content: 'Yo' }
|
|
36
|
+
] );
|
|
37
|
+
} );
|
|
38
|
+
|
|
39
|
+
it( 'attaches parsed attributes to the block', () => {
|
|
40
|
+
const blocks = tokenizeBlocks( '<system options="a b" pinned>Hi</system>' );
|
|
41
|
+
expect( blocks[0] ).toEqual( {
|
|
42
|
+
role: 'system',
|
|
43
|
+
content: 'Hi',
|
|
44
|
+
attributes: { options: 'a b', pinned: true }
|
|
45
|
+
} );
|
|
46
|
+
} );
|
|
47
|
+
|
|
48
|
+
it( 'captures unknown attributes generically (validation rejects them later)', () => {
|
|
49
|
+
const blocks = tokenizeBlocks( '<user data="x">Hi</user>' );
|
|
50
|
+
expect( blocks[0].attributes ).toEqual( { data: 'x' } );
|
|
51
|
+
} );
|
|
52
|
+
|
|
53
|
+
it( 'treats angle-bracket markup inside a block as opaque content', () => {
|
|
54
|
+
const blocks = tokenizeBlocks( '<user>Compare <div> and <span> tags</user>' );
|
|
55
|
+
expect( blocks[0] ).toEqual( { role: 'user', content: 'Compare <div> and <span> tags' } );
|
|
56
|
+
} );
|
|
57
|
+
|
|
58
|
+
it( 'tokenizes every registered role', () => {
|
|
59
|
+
const body = [ ...BLOCK_ROLES ].map( role => `<${role}>${role} body</${role}>` ).join( '\n' );
|
|
60
|
+
const blocks = tokenizeBlocks( body );
|
|
61
|
+
expect( blocks.map( block => block.role ) ).toEqual( [ ...BLOCK_ROLES ] );
|
|
62
|
+
} );
|
|
63
|
+
} );
|
package/src/prompt/parser.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import matter from 'gray-matter';
|
|
2
2
|
import { FatalError } from '@outputai/core';
|
|
3
|
+
import { tokenizeBlocks } from './blocks.js';
|
|
3
4
|
|
|
4
5
|
export function parsePrompt( { name, raw } ) {
|
|
5
6
|
const { data: config, content } = matter( raw );
|
|
@@ -8,11 +9,7 @@ export function parsePrompt( { name, raw } ) {
|
|
|
8
9
|
throw new FatalError( `Prompt "${name}" has no content after frontmatter` );
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
const
|
|
12
|
-
const messages = [ ...content.matchAll( infoExtractor ) ].map(
|
|
13
|
-
( [ _, role, text ] ) => ( { role, content: text.trim() } )
|
|
14
|
-
);
|
|
15
|
-
|
|
12
|
+
const messages = tokenizeBlocks( content );
|
|
16
13
|
const instructions = messages.length === 0 ? content.trim() : null;
|
|
17
14
|
|
|
18
15
|
return { config, messages, instructions };
|
|
@@ -164,4 +164,23 @@ model: claude-3-5-sonnet-20241022
|
|
|
164
164
|
] );
|
|
165
165
|
expect( result.instructions ).toBeNull();
|
|
166
166
|
} );
|
|
167
|
+
|
|
168
|
+
it( 'surfaces block opening-tag attributes as an attributes object', () => {
|
|
169
|
+
const raw = `---
|
|
170
|
+
provider: anthropic
|
|
171
|
+
model: claude-sonnet-4-5
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
<system options="cached">Static.</system>
|
|
175
|
+
<user>Question</user>`;
|
|
176
|
+
|
|
177
|
+
const result = parsePrompt( { name: 'test', raw } );
|
|
178
|
+
|
|
179
|
+
expect( result.messages[0] ).toEqual( {
|
|
180
|
+
role: 'system',
|
|
181
|
+
content: 'Static.',
|
|
182
|
+
attributes: { options: 'cached' }
|
|
183
|
+
} );
|
|
184
|
+
expect( result.messages[1] ).toEqual( { role: 'user', content: 'Question' } );
|
|
185
|
+
} );
|
|
167
186
|
} );
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { ValidationError, z } from '@outputai/core';
|
|
2
|
+
import { attributesSchema } from './block_options.js';
|
|
2
3
|
|
|
3
4
|
const toolConfigSchema = z.record( z.string(), z.unknown() );
|
|
4
5
|
const toolsConfigSchema = z.record( z.string(), toolConfigSchema );
|
|
5
6
|
|
|
7
|
+
// A provider-namespaced options object, e.g. { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
|
8
|
+
const providerOptionsSchema = z.record( z.string(), z.record( z.string(), z.unknown() ) );
|
|
9
|
+
|
|
6
10
|
export const promptSchema = z.object( {
|
|
7
11
|
name: z.string(),
|
|
8
12
|
config: z.object( {
|
|
@@ -22,12 +26,14 @@ export const promptSchema = z.object( {
|
|
|
22
26
|
type: z.enum( [ 'enabled', 'disabled' ] ),
|
|
23
27
|
budgetTokens: z.number().optional()
|
|
24
28
|
} ).loose().optional()
|
|
25
|
-
} ).loose().optional()
|
|
29
|
+
} ).loose().optional(),
|
|
30
|
+
messageOptions: z.record( z.string(), providerOptionsSchema ).optional()
|
|
26
31
|
} ).loose(),
|
|
27
32
|
messages: z.array(
|
|
28
33
|
z.object( {
|
|
29
34
|
role: z.string(),
|
|
30
|
-
content: z.string()
|
|
35
|
+
content: z.string(),
|
|
36
|
+
attributes: attributesSchema.optional()
|
|
31
37
|
} ).strict()
|
|
32
38
|
),
|
|
33
39
|
instructions: z.string().trim().min( 1 ).nullable().optional()
|
|
@@ -596,4 +596,53 @@ describe( 'validatePrompt', () => {
|
|
|
596
596
|
|
|
597
597
|
expect( () => validatePrompt( maxTokensSnakeCase ) ).not.toThrow();
|
|
598
598
|
} );
|
|
599
|
+
|
|
600
|
+
it( 'should validate the options attribute referencing messageOptions sets', () => {
|
|
601
|
+
const promptWithMessageOptions = {
|
|
602
|
+
name: 'message-options-prompt',
|
|
603
|
+
config: {
|
|
604
|
+
provider: 'anthropic',
|
|
605
|
+
model: 'claude-sonnet-4-5',
|
|
606
|
+
messageOptions: {
|
|
607
|
+
cached: { anthropic: { cacheControl: { type: 'ephemeral' } } }
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
messages: [
|
|
611
|
+
{ role: 'system', content: 'Docs.', attributes: { options: 'cached' } },
|
|
612
|
+
{ role: 'user', content: 'Question' }
|
|
613
|
+
]
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
expect( () => validatePrompt( promptWithMessageOptions ) ).not.toThrow();
|
|
617
|
+
} );
|
|
618
|
+
|
|
619
|
+
it( 'should reject the removed cache shorthand as an unknown block attribute', () => {
|
|
620
|
+
const cacheShorthandPrompt = {
|
|
621
|
+
name: 'cache-shorthand-prompt',
|
|
622
|
+
config: {
|
|
623
|
+
provider: 'anthropic',
|
|
624
|
+
model: 'claude-sonnet-4-5'
|
|
625
|
+
},
|
|
626
|
+
messages: [
|
|
627
|
+
{ role: 'system', content: 'Static.', attributes: { cache: true } }
|
|
628
|
+
]
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
expect( () => validatePrompt( cacheShorthandPrompt ) ).toThrow( ValidationError );
|
|
632
|
+
} );
|
|
633
|
+
|
|
634
|
+
it( 'should throw ValidationError for unknown top-level message fields', () => {
|
|
635
|
+
const unknownFieldPrompt = {
|
|
636
|
+
name: 'unknown-field-prompt',
|
|
637
|
+
config: {
|
|
638
|
+
provider: 'anthropic',
|
|
639
|
+
model: 'claude-sonnet-4-5'
|
|
640
|
+
},
|
|
641
|
+
messages: [
|
|
642
|
+
{ role: 'user', content: 'Hi', options: 'cached' }
|
|
643
|
+
]
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
expect( () => validatePrompt( unknownFieldPrompt ) ).toThrow( ValidationError );
|
|
647
|
+
} );
|
|
599
648
|
} );
|