@outputai/llm 0.7.0 → 0.7.1-next.0b398c7.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@outputai/llm",
3
- "version": "0.7.0",
3
+ "version": "0.7.1-next.0b398c7.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.0b398c7.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "ai": "6.0.168",
package/src/agent.js CHANGED
@@ -5,7 +5,7 @@ import { loadAiSdkTextOptions } from './ai_sdk_options.js';
5
5
  import { prepareTextPrompt } from './prompt/prepare_text.js';
6
6
  import { startTrace, endTraceWithError } from './utils/trace.js';
7
7
  import { wrapTextResponse, wrapStreamOnFinishResponse } from './utils/response_wrappers.js';
8
- import { ROLE, isRole, getContent } from './utils/message.js';
8
+ import { ROLE, isRole } from './utils/message.js';
9
9
  export { skill } from './prompt/skill.js';
10
10
 
11
11
  export const createMemoryConversationStore = () => {
@@ -43,16 +43,13 @@ export class Agent extends AIToolLoopAgent {
43
43
 
44
44
  const { loadedPrompt, tools } = prepareTextPrompt( { prompt, variables, promptDir: resolvedPromptDir, skills, tools: toolsArg } );
45
45
 
46
- const { messages: allMessages, ...constructorOptions } = loadAiSdkTextOptions( loadedPrompt );
47
-
48
- // Extract system messages as `instructions` for the ToolLoopAgent constructor
49
- // and keep user messages for generate() calls — avoids provider errors
50
- // with multiple system messages during multi-step tool loops
51
- const systemContent = allMessages.filter( isRole( ROLE.SYSTEM ) ).map( getContent ).join( '\n\n' );
46
+ const { system, messages, ...constructorOptions } = loadAiSdkTextOptions( loadedPrompt );
52
47
 
48
+ // loadAiSdkTextOptions routes system blocks to the `system` slot (preserving
49
+ // per-message providerOptions); pass them as the agent's `instructions`.
53
50
  super( {
54
51
  ...constructorOptions,
55
- ...( systemContent ? { instructions: systemContent } : {} ),
52
+ ...( system.length > 0 ? { instructions: system } : {} ),
56
53
  ...( tools ? { tools } : {} ),
57
54
  stopWhen: stopWhen ?? stepCountIs( maxSteps ),
58
55
  ...rest
@@ -60,7 +57,9 @@ export class Agent extends AIToolLoopAgent {
60
57
 
61
58
  this.#prompt = prompt;
62
59
  this.#modelId = loadedPrompt.config.model;
63
- this.#initialMessages = allMessages.filter( isRole( ROLE.USER ) );
60
+ // `messages` is system-free but may still hold authored <assistant>/<tool>
61
+ // blocks; seed only <user> turns into each generate()/stream() call.
62
+ this.#initialMessages = messages.filter( isRole( ROLE.USER ) );
64
63
  this.#store = conversationStore ?? null;
65
64
  }
66
65
 
@@ -79,7 +78,7 @@ export class Agent extends AIToolLoopAgent {
79
78
  const traceId = startTrace( { name: 'Agent.generate', prompt: this.#prompt } );
80
79
  try {
81
80
  const messages = await this.#fetchMessages( userMessages );
82
- const response = await super.generate( { messages, ...callOptions } );
81
+ const response = await super.generate( { messages, allowSystemInMessages: true, ...callOptions } );
83
82
  const wrapped = await wrapTextResponse( { traceId, response, modelId: this.#modelId } );
84
83
  await this.#storeMessages( userMessages, wrapped );
85
84
  return wrapped;
@@ -95,6 +94,7 @@ export class Agent extends AIToolLoopAgent {
95
94
  const messages = await this.#fetchMessages( userMessages );
96
95
  return super.stream( {
97
96
  messages,
97
+ allowSystemInMessages: true,
98
98
  ...callOptions,
99
99
  ...wrapStreamOnFinishResponse( { traceId, modelId: this.#modelId, onFinish } ),
100
100
  onError( event ) {
package/src/agent.spec.js CHANGED
@@ -86,7 +86,8 @@ vi.mock( './utils/trace.js', () => ( {
86
86
 
87
87
  vi.mock( './utils/response_wrappers.js', () => ( {
88
88
  wrapTextResponse: ( ...args ) => wrapMocks.wrapTextResponse( ...args ),
89
- wrapStreamOnFinishResponse: ( ...args ) => wrapMocks.wrapStreamOnFinishResponse( ...args )
89
+ wrapStreamOnFinishResponse: ( ...args ) =>
90
+ wrapMocks.wrapStreamOnFinishResponse( ...args )
90
91
  } ) );
91
92
 
92
93
  vi.mock( './prompt/skill.js', () => ( {
@@ -112,7 +113,8 @@ const model = { id: 'MODEL' };
112
113
 
113
114
  const textOptions = {
114
115
  model,
115
- messages: loadedPrompt.messages,
116
+ system: [ { role: 'system', content: 'You are concise.' } ],
117
+ messages: [ { role: 'user', content: 'Initial user message' } ],
116
118
  providerOptions: { test: true },
117
119
  temperature: 0.3
118
120
  };
@@ -131,7 +133,9 @@ describe( 'Agent', () => {
131
133
  aiMocks.superConstructor.mockReset();
132
134
  aiMocks.superGenerate.mockReset().mockResolvedValue( aiResponse );
133
135
  aiMocks.superStream.mockReset().mockReturnValue( { textStream: 'stream' } );
134
- aiMocks.stepCountIs.mockReset().mockImplementation( count => ( { type: 'step-count', count } ) );
136
+ aiMocks.stepCountIs
137
+ .mockReset()
138
+ .mockImplementation( count => ( { type: 'step-count', count } ) );
135
139
 
136
140
  promptMocks.prepareTextPrompt.mockReset().mockReturnValue( {
137
141
  loadedPrompt,
@@ -143,7 +147,9 @@ describe( 'Agent', () => {
143
147
  traceMocks.startTrace.mockReset().mockReturnValue( 'trace-id' );
144
148
  traceMocks.endTraceWithError.mockReset();
145
149
 
146
- wrapMocks.wrapTextResponse.mockReset().mockImplementation( async ( { response } ) => response );
150
+ wrapMocks.wrapTextResponse
151
+ .mockReset()
152
+ .mockImplementation( async ( { response } ) => response );
147
153
  wrapMocks.wrapStreamOnFinishResponse.mockReset().mockReturnValue( {
148
154
  onFinish: vi.fn()
149
155
  } );
@@ -175,7 +181,9 @@ describe( 'Agent', () => {
175
181
 
176
182
  it( 'prepares the prompt using the resolved invocation dir', async () => {
177
183
  const { Agent } = await importSut();
178
- const skills = [ { name: 'style', description: 'Style', instructions: '# Style' } ];
184
+ const skills = [
185
+ { name: 'style', description: 'Style', instructions: '# Style' }
186
+ ];
179
187
  const tools = { search: { description: 'Search' } };
180
188
 
181
189
  new Agent( {
@@ -199,9 +207,11 @@ describe( 'Agent', () => {
199
207
 
200
208
  new Agent( { prompt: 'test@v1', promptDir: '/explicit/prompts' } );
201
209
 
202
- expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith( expect.objectContaining( {
203
- promptDir: '/explicit/prompts'
204
- } ) );
210
+ expect( promptMocks.prepareTextPrompt ).toHaveBeenCalledWith(
211
+ expect.objectContaining( {
212
+ promptDir: '/explicit/prompts'
213
+ } )
214
+ );
205
215
  } );
206
216
 
207
217
  it( 'constructs ToolLoopAgent with text options, instructions, tools, and default stopWhen', async () => {
@@ -215,12 +225,34 @@ describe( 'Agent', () => {
215
225
  model,
216
226
  providerOptions: { test: true },
217
227
  temperature: 0.3,
218
- instructions: 'You are concise.',
228
+ instructions: [ { role: 'system', content: 'You are concise.' } ],
219
229
  tools: preparedTools,
220
230
  stopWhen: { type: 'step-count', count: 10 }
221
231
  } );
222
232
  } );
223
233
 
234
+ it( 'preserves per-message providerOptions on system messages passed as instructions', async () => {
235
+ const { Agent } = await importSut();
236
+ const systemMessage = {
237
+ role: 'system',
238
+ content: 'You are concise.',
239
+ providerOptions: { anthropic: { cacheControl: { type: 'ephemeral' } } }
240
+ };
241
+ optionMocks.loadAiSdkTextOptions.mockReturnValueOnce( {
242
+ model,
243
+ system: [ systemMessage ],
244
+ messages: [ { role: 'user', content: 'Hello' } ]
245
+ } );
246
+
247
+ new Agent( { prompt: 'test@v1' } );
248
+
249
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith(
250
+ expect.objectContaining( {
251
+ instructions: [ systemMessage ]
252
+ } )
253
+ );
254
+ } );
255
+
224
256
  it( 'omits tools when prompt preparation returns null tools', async () => {
225
257
  const { Agent } = await importSut();
226
258
  promptMocks.prepareTextPrompt.mockReturnValueOnce( {
@@ -234,7 +266,7 @@ describe( 'Agent', () => {
234
266
  model,
235
267
  providerOptions: { test: true },
236
268
  temperature: 0.3,
237
- instructions: 'You are concise.',
269
+ instructions: [ { role: 'system', content: 'You are concise.' } ],
238
270
  stopWhen: { type: 'step-count', count: 10 }
239
271
  } );
240
272
  } );
@@ -246,9 +278,11 @@ describe( 'Agent', () => {
246
278
  new Agent( { prompt: 'test@v1', stopWhen } );
247
279
 
248
280
  expect( aiMocks.stepCountIs ).not.toHaveBeenCalled();
249
- expect( aiMocks.superConstructor ).toHaveBeenCalledWith( expect.objectContaining( {
250
- stopWhen
251
- } ) );
281
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith(
282
+ expect.objectContaining( {
283
+ stopWhen
284
+ } )
285
+ );
252
286
  } );
253
287
 
254
288
  it( 'passes custom constructor options through', async () => {
@@ -256,10 +290,12 @@ describe( 'Agent', () => {
256
290
 
257
291
  new Agent( { prompt: 'test@v1', temperature: 0.8, seed: 42 } );
258
292
 
259
- expect( aiMocks.superConstructor ).toHaveBeenCalledWith( expect.objectContaining( {
260
- temperature: 0.8,
261
- seed: 42
262
- } ) );
293
+ expect( aiMocks.superConstructor ).toHaveBeenCalledWith(
294
+ expect.objectContaining( {
295
+ temperature: 0.8,
296
+ seed: 42
297
+ } )
298
+ );
263
299
  } );
264
300
 
265
301
  it( 'keeps only user prompt messages as initial generate messages', async () => {
@@ -269,13 +305,36 @@ describe( 'Agent', () => {
269
305
  await agent.generate();
270
306
 
271
307
  expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
272
- messages: [ { role: 'user', content: 'Initial user message' } ]
308
+ messages: [ { role: 'user', content: 'Initial user message' } ],
309
+ allowSystemInMessages: true
310
+ } );
311
+ } );
312
+
313
+ it( 'excludes authored assistant/tool blocks from the initial generate messages', async () => {
314
+ const { Agent } = await importSut();
315
+ optionMocks.loadAiSdkTextOptions.mockReturnValueOnce( {
316
+ model,
317
+ system: [ { role: 'system', content: 'You are concise.' } ],
318
+ messages: [
319
+ { role: 'user', content: 'Initial user message' },
320
+ { role: 'assistant', content: 'Authored assistant block' }
321
+ ]
322
+ } );
323
+ const agent = new Agent( { prompt: 'test@v1' } );
324
+
325
+ await agent.generate();
326
+
327
+ expect( aiMocks.superGenerate ).toHaveBeenCalledWith( {
328
+ messages: [ { role: 'user', content: 'Initial user message' } ],
329
+ allowSystemInMessages: true
273
330
  } );
274
331
  } );
275
332
 
276
333
  it( 'combines initial, stored, and caller messages for generate', async () => {
277
334
  const store = {
278
- getMessages: vi.fn( () => [ { role: 'assistant', content: 'Stored reply' } ] ),
335
+ getMessages: vi.fn( () => [
336
+ { role: 'assistant', content: 'Stored reply' }
337
+ ] ),
279
338
  addMessages: vi.fn()
280
339
  };
281
340
  const callerMessage = { role: 'user', content: 'New question' };
@@ -290,6 +349,7 @@ describe( 'Agent', () => {
290
349
  { role: 'assistant', content: 'Stored reply' },
291
350
  callerMessage
292
351
  ],
352
+ allowSystemInMessages: true,
293
353
  maxRetries: 1
294
354
  } );
295
355
  } );
@@ -336,7 +396,9 @@ describe( 'Agent', () => {
336
396
 
337
397
  it( 'streams with initial, stored, and caller messages', async () => {
338
398
  const store = {
339
- getMessages: vi.fn( () => [ { role: 'assistant', content: 'Stored reply' } ] ),
399
+ getMessages: vi.fn( () => [
400
+ { role: 'assistant', content: 'Stored reply' }
401
+ ] ),
340
402
  addMessages: vi.fn()
341
403
  };
342
404
  const onFinish = vi.fn();
@@ -345,7 +407,12 @@ describe( 'Agent', () => {
345
407
  const { Agent } = await importSut();
346
408
  const agent = new Agent( { prompt: 'test@v1', conversationStore: store } );
347
409
 
348
- const result = await agent.stream( { messages: [ callerMessage ], onFinish, onError, maxRetries: 1 } );
410
+ const result = await agent.stream( {
411
+ messages: [ callerMessage ],
412
+ onFinish,
413
+ onError,
414
+ maxRetries: 1
415
+ } );
349
416
 
350
417
  expect( traceMocks.startTrace ).toHaveBeenCalledWith( {
351
418
  name: 'Agent.stream',
@@ -362,6 +429,7 @@ describe( 'Agent', () => {
362
429
  { role: 'assistant', content: 'Stored reply' },
363
430
  callerMessage
364
431
  ],
432
+ allowSystemInMessages: true,
365
433
  maxRetries: 1,
366
434
  onFinish: expect.any( Function ),
367
435
  onError: expect.any( Function )
package/src/ai_sdk.js CHANGED
@@ -22,6 +22,7 @@ export async function generateText( { prompt, variables, promptDir, skills = [],
22
22
  try {
23
23
  const response = await AI.generateText( {
24
24
  ...loadAiSdkTextOptions( loadedPrompt ),
25
+ allowSystemInMessages: true,
25
26
  maxRetries: 0,
26
27
  ...aiSdkArgs,
27
28
  ...( tools && { tools } ),
@@ -50,6 +51,7 @@ export function streamText( { prompt, variables, promptDir, skills = [], maxStep
50
51
  try {
51
52
  return AI.streamText( {
52
53
  ...loadAiSdkTextOptions( loadedPrompt ),
54
+ allowSystemInMessages: true,
53
55
  maxRetries: 0,
54
56
  ...aiSdkArgs,
55
57
  ...( tools && { tools } ),
@@ -190,6 +190,7 @@ describe( 'ai_sdk', () => {
190
190
  expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
191
191
  expect( aiFns.generateText ).toHaveBeenCalledWith( {
192
192
  ...textOptions,
193
+ allowSystemInMessages: true,
193
194
  maxRetries: 0,
194
195
  tools,
195
196
  temperature: 0.2,
@@ -225,6 +226,7 @@ describe( 'ai_sdk', () => {
225
226
  expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
226
227
  expect( aiFns.generateText ).toHaveBeenCalledWith( {
227
228
  ...textOptions,
229
+ allowSystemInMessages: true,
228
230
  maxRetries: 0
229
231
  } );
230
232
  } );
@@ -326,6 +328,7 @@ describe( 'ai_sdk', () => {
326
328
  expect( aiFns.stepCountIs ).toHaveBeenCalledWith( 4 );
327
329
  expect( aiFns.streamText ).toHaveBeenCalledWith( {
328
330
  ...textOptions,
331
+ allowSystemInMessages: true,
329
332
  maxRetries: 0,
330
333
  tools,
331
334
  temperature: 0.2,
@@ -373,6 +376,7 @@ describe( 'ai_sdk', () => {
373
376
  expect( aiFns.stepCountIs ).not.toHaveBeenCalled();
374
377
  expect( aiFns.streamText ).toHaveBeenCalledWith( {
375
378
  ...textOptions,
379
+ allowSystemInMessages: true,
376
380
  maxRetries: 0,
377
381
  onFinish: expect.any( Function ),
378
382
  onError: expect.any( Function )
@@ -1,9 +1,16 @@
1
1
  import { loadImageModel, loadTextModel, loadTools } from './ai_model.js';
2
+ import { resolveMessageProviderOptions } from './prompt/block_options.js';
3
+ import { ROLE, isRole } from './utils/message.js';
2
4
  import { FatalError } from '@outputai/core';
3
5
 
4
6
  /**
5
7
  * Convert a loaded prompt into AI SDK text generation options.
6
8
  *
9
+ * System blocks are routed to the `system` option (as `SystemModelMessage[]`, so
10
+ * per-message providerOptions like `cacheControl` are preserved) rather than left
11
+ * in `messages` — the AI SDK flags system roles inside `messages` as a prompt
12
+ * injection risk, and `system` is the provider-recommended slot.
13
+ *
7
14
  * @param {object} prompt - Loaded prompt object
8
15
  * @returns {object} Options for AI SDK text calls
9
16
  */
@@ -11,9 +18,13 @@ export const loadAiSdkTextOptions = prompt => {
11
18
  if ( prompt.messages.length === 0 ) {
12
19
  throw new FatalError( `Prompt "${prompt.name}" has no chat-style messages. Add role-tagged blocks like <system> or <user>.` );
13
20
  }
21
+ const isSystem = isRole( ROLE.SYSTEM );
22
+ const resolvedMessages = resolveMessageProviderOptions( prompt );
23
+
14
24
  const options = {
15
25
  model: loadTextModel( prompt ),
16
- messages: prompt.messages,
26
+ system: resolvedMessages.filter( isSystem ),
27
+ messages: resolvedMessages.filter( message => !isSystem( message ) ),
17
28
  providerOptions: prompt.config.providerOptions
18
29
  };
19
30
 
@@ -60,7 +60,8 @@ describe( 'ai_sdk_options', () => {
60
60
  expect( loadToolsImpl ).toHaveBeenCalledWith( prompt );
61
61
  expect( result ).toEqual( {
62
62
  model: 'MODEL',
63
- messages: prompt.messages,
63
+ system: [ { role: 'system', content: 'You are concise.' } ],
64
+ messages: [ { role: 'user', content: 'Hello' } ],
64
65
  providerOptions: prompt.config.providerOptions,
65
66
  temperature: 0.3,
66
67
  maxOutputTokens: 1000
@@ -161,4 +162,69 @@ describe( 'ai_sdk_options', () => {
161
162
  );
162
163
  expect( loadImageModelImpl ).not.toHaveBeenCalled();
163
164
  } );
165
+
166
+ it( 'resolves block attributes into per-message providerOptions', async () => {
167
+ const prompt = {
168
+ name: 'cache@v1',
169
+ config: {
170
+ provider: 'anthropic',
171
+ model: 'claude-sonnet-4-5',
172
+ messageOptions: { cached: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } } }
173
+ },
174
+ messages: [
175
+ { role: 'system', content: 'Static', attributes: { options: 'cached' } },
176
+ { role: 'user', content: 'Hello' }
177
+ ],
178
+ instructions: null
179
+ };
180
+
181
+ const { loadAiSdkTextOptions } = await importSut();
182
+ const result = loadAiSdkTextOptions( prompt );
183
+
184
+ expect( result.system ).toEqual( [
185
+ {
186
+ role: 'system',
187
+ content: 'Static',
188
+ providerOptions: { anthropic: { cacheControl: { type: 'ephemeral', ttl: '1h' } } }
189
+ }
190
+ ] );
191
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
192
+ } );
193
+
194
+ it( 'returns an empty system array when the prompt has no system block', async () => {
195
+ const prompt = {
196
+ name: 'no-system@v1',
197
+ config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
198
+ messages: [ { role: 'user', content: 'Hello' } ],
199
+ instructions: null
200
+ };
201
+
202
+ const { loadAiSdkTextOptions } = await importSut();
203
+ const result = loadAiSdkTextOptions( prompt );
204
+
205
+ expect( result.system ).toEqual( [] );
206
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
207
+ } );
208
+
209
+ it( 'groups multiple system blocks into the system option and keeps them out of messages', async () => {
210
+ const prompt = {
211
+ name: 'multi-system@v1',
212
+ config: { provider: 'anthropic', model: 'claude-haiku-4-5' },
213
+ messages: [
214
+ { role: 'system', content: 'First' },
215
+ { role: 'system', content: 'Second' },
216
+ { role: 'user', content: 'Hello' }
217
+ ],
218
+ instructions: null
219
+ };
220
+
221
+ const { loadAiSdkTextOptions } = await importSut();
222
+ const result = loadAiSdkTextOptions( prompt );
223
+
224
+ expect( result.system ).toEqual( [
225
+ { role: 'system', content: 'First' },
226
+ { role: 'system', content: 'Second' }
227
+ ] );
228
+ expect( result.messages ).toEqual( [ { role: 'user', content: 'Hello' } ] );
229
+ } );
164
230
  } );
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
- if ( Number.isFinite( pricing.cache_read ) && Number.isFinite( cachedInputTokens ) ) {
36
- llmUsage.addUsage( { type: 'input_cached', ppm: pricing.cache_read, amount: cachedInputTokens } );
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 } );
@@ -132,7 +132,7 @@ describe( 'calculateLLMCallCost', () => {
132
132
  } );
133
133
  } );
134
134
 
135
- it( 'omits cached usage when model has no cache_read rate', async () => {
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: 800_000
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 | true>;
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
+ } );
@@ -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,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
  } );