@outputai/llm 0.6.1-next.fc6a93e.0 → 0.7.1-dev.144d64f.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.
@@ -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
+ } );
@@ -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 infoExtractor = /<(system|user|assistant|tool)>([\s\S]*?)<\/\1>/gm;
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,4 +1,11 @@
1
1
  import { ValidationError, z } from '@outputai/core';
2
+ import { attributesSchema } from './block_options.js';
3
+
4
+ const toolConfigSchema = z.record( z.string(), z.unknown() );
5
+ const toolsConfigSchema = z.record( z.string(), toolConfigSchema );
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() ) );
2
9
 
3
10
  export const promptSchema = z.object( {
4
11
  name: z.string(),
@@ -13,18 +20,20 @@ export const promptSchema = z.object( {
13
20
  aspectRatio: z.string().regex( /^\d+:\d+$/ ).optional(),
14
21
  seed: z.number().int().optional(),
15
22
  skills: z.union( [ z.string().min( 1 ), z.array( z.string().min( 1 ) ) ] ).optional(),
16
- tools: z.record( z.string(), z.object( {} ).loose() ).optional(),
23
+ tools: toolsConfigSchema.optional(),
17
24
  providerOptions: z.object( {
18
25
  thinking: z.object( {
19
26
  type: z.enum( [ 'enabled', 'disabled' ] ),
20
27
  budgetTokens: z.number().optional()
21
28
  } ).loose().optional()
22
- } ).loose().optional()
29
+ } ).loose().optional(),
30
+ messageOptions: z.record( z.string(), providerOptionsSchema ).optional()
23
31
  } ).loose(),
24
32
  messages: z.array(
25
33
  z.object( {
26
34
  role: z.string(),
27
- content: z.string()
35
+ content: z.string(),
36
+ attributes: attributesSchema.optional()
28
37
  } ).strict()
29
38
  ),
30
39
  instructions: z.string().trim().min( 1 ).nullable().optional()
@@ -384,6 +384,71 @@ describe( 'validatePrompt', () => {
384
384
  expect( () => validatePrompt( promptWithSkill ) ).not.toThrow();
385
385
  } );
386
386
 
387
+ it( 'should validate provider tool config records', () => {
388
+ const promptWithTools = {
389
+ name: 'tools-prompt',
390
+ config: {
391
+ provider: 'vertex',
392
+ model: 'gemini-2.0-flash',
393
+ tools: {
394
+ googleSearch: {
395
+ mode: 'MODE_DYNAMIC',
396
+ dynamicThreshold: 0.8
397
+ },
398
+ urlContext: {}
399
+ }
400
+ },
401
+ messages: [
402
+ {
403
+ role: 'user',
404
+ content: 'Research this.'
405
+ }
406
+ ]
407
+ };
408
+
409
+ expect( () => validatePrompt( promptWithTools ) ).not.toThrow();
410
+ } );
411
+
412
+ it( 'should throw ValidationError when tools config is not a record', () => {
413
+ const invalidToolsPrompt = {
414
+ name: 'invalid-tools-prompt',
415
+ config: {
416
+ provider: 'vertex',
417
+ model: 'gemini-2.0-flash',
418
+ tools: [ 'googleSearch' ]
419
+ },
420
+ messages: [
421
+ {
422
+ role: 'user',
423
+ content: 'Research this.'
424
+ }
425
+ ]
426
+ };
427
+
428
+ expect( () => validatePrompt( invalidToolsPrompt ) ).toThrow( ValidationError );
429
+ } );
430
+
431
+ it( 'should throw ValidationError when a tool config is not a record', () => {
432
+ const invalidToolConfigPrompt = {
433
+ name: 'invalid-tool-config-prompt',
434
+ config: {
435
+ provider: 'vertex',
436
+ model: 'gemini-2.0-flash',
437
+ tools: {
438
+ googleSearch: 'MODE_DYNAMIC'
439
+ }
440
+ },
441
+ messages: [
442
+ {
443
+ role: 'user',
444
+ content: 'Research this.'
445
+ }
446
+ ]
447
+ };
448
+
449
+ expect( () => validatePrompt( invalidToolConfigPrompt ) ).toThrow( ValidationError );
450
+ } );
451
+
387
452
  it( 'should throw ValidationError for invalid skill path config', () => {
388
453
  const invalidSkillsPrompt = {
389
454
  name: 'invalid-skills-prompt',
@@ -531,4 +596,53 @@ describe( 'validatePrompt', () => {
531
596
 
532
597
  expect( () => validatePrompt( maxTokensSnakeCase ) ).not.toThrow();
533
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
+ } );
534
648
  } );
@@ -13,6 +13,9 @@ import {
13
13
  } from 'ai';
14
14
  import { FatalError } from '@outputai/core';
15
15
 
16
+ // AI SDK does not expose a dedicated schema-mismatch discriminator for NoObjectGeneratedError.
17
+ const NO_OBJECT_SCHEMA_MISMATCH_MESSAGE = 'No object generated: response did not match schema.';
18
+
16
19
  /**
17
20
  * Recursively search an error cause chain until finds an error which is instance of given prototype.
18
21
  *
@@ -25,7 +28,7 @@ export const findInstanceInCauseChain = ( error, _class, depth = 0 ) => {
25
28
  if ( !error || typeof error !== 'object' ) {
26
29
  return null;
27
30
  }
28
- if ( typeof _class === 'string' && error.constructor.name === _class ) {
31
+ if ( typeof _class === 'string' && error.constructor?.name === _class ) {
29
32
  return error;
30
33
  }
31
34
  if ( typeof _class === 'function' && error instanceof _class ) {
@@ -42,20 +45,34 @@ const toFatalError = ( error, extraMessage = '' ) => new FatalError(
42
45
  { cause: error }
43
46
  );
44
47
 
48
+ /**
49
+ * Map an AI SDK error to a framework specific error:
50
+ *
51
+ * - AI SDK Unrecoverable errors become FatalErrors, check code to see options.
52
+ * - NoObjectGeneratedError from invalid schema are reinitialized with a better message.
53
+ * - Other errors are preserved.
54
+ * @param {object} error - Original Error
55
+ * @returns {object} A new Error
56
+ */
45
57
  export const mapAiError = error => {
46
58
  if ( error instanceof FatalError ) {
47
59
  return error;
48
60
  }
49
61
 
50
- // NoObjectGeneratedError can be thrown when the response doesn't match the schema
51
- // This adds a wrapper to that error serializing the first zod validation in the message, to make it easier to debug.
52
- if ( NoObjectGeneratedError.isInstance( error ) && error.message.includes( 'No object generated: response did not match schema.' ) ) {
62
+ // NoObjectGeneratedError can be thrown when the response doesn't match the schema.
63
+ // This re-creates the error with a better message, making it easier to debug.
64
+ if ( NoObjectGeneratedError.isInstance( error ) && error.message.includes( NO_OBJECT_SCHEMA_MISMATCH_MESSAGE ) ) {
53
65
  const zodError = findInstanceInCauseChain( error, 'ZodError' );
54
66
  if ( zodError && zodError.issues?.length > 0 ) {
55
- const { path, message } = zodError.issues[0];
56
- const wrapper = new Error( `${error.message} First issue is "${message}" at path [${path.join( ', ' )}].`, { cause: error } );
57
- wrapper.name = 'NoObjectGeneratedError';
58
- return wrapper;
67
+ const [ { path, message } ] = zodError.issues;
68
+ return new NoObjectGeneratedError( {
69
+ message: `${error.message} First issue is "${message}" at path [${path.join( ', ' )}].`,
70
+ cause: error.cause,
71
+ text: error.text,
72
+ response: error.response,
73
+ usage: error.usage,
74
+ finishReason: error.finishReason
75
+ } );
59
76
  }
60
77
  return error;
61
78
  }
@@ -171,6 +171,13 @@ describe( 'findInstanceInCauseChain', () => {
171
171
  expect( findInstanceInCauseChain( 'not an error', Error ) ).toBeNull();
172
172
  } );
173
173
 
174
+ it( 'returns null for object causes without constructors', () => {
175
+ const cause = Object.create( null );
176
+ const error = new FirstCustomError( 'first', { cause } );
177
+
178
+ expect( findInstanceInCauseChain( error, 'SecondCustomError' ) ).toBeNull();
179
+ } );
180
+
174
181
  it( 'stops searching after the depth limit', () => {
175
182
  const makeErrorChain = depth => depth === 0 ?
176
183
  new SecondCustomError( 'target' ) :
@@ -204,17 +211,25 @@ describe( 'mapAiError', () => {
204
211
  const error = new NoObjectGeneratedError( {
205
212
  message: 'No object generated: response did not match schema.',
206
213
  text: '{"items":[{}]}',
214
+ response: { id: 'response-1' },
215
+ usage: { totalTokens: 10 },
216
+ finishReason: 'stop',
207
217
  cause: validationError
208
218
  } );
209
219
 
210
220
  const result = mapAiError( error );
211
221
 
212
222
  expect( result ).not.toBe( error );
213
- expect( result.name ).toBe( 'NoObjectGeneratedError' );
223
+ expect( NoObjectGeneratedError.isInstance( result ) ).toBe( true );
224
+ expect( result.name ).toBe( 'AI_NoObjectGeneratedError' );
214
225
  expect( result.message ).toBe(
215
226
  'No object generated: response did not match schema. First issue is "Expected string" at path [items, 0, title].'
216
227
  );
217
- expect( result.cause ).toBe( error );
228
+ expect( result.cause ).toBe( validationError );
229
+ expect( result.text ).toBe( error.text );
230
+ expect( result.response ).toBe( error.response );
231
+ expect( result.usage ).toBe( error.usage );
232
+ expect( result.finishReason ).toBe( error.finishReason );
218
233
  } );
219
234
 
220
235
  it( 'preserves NoObjectGeneratedError schema mismatches when no schema issue is available', () => {