@tanstack/ai 0.19.0 → 0.20.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/src/index.ts CHANGED
@@ -104,6 +104,10 @@ export type {
104
104
  // All types
105
105
  export * from './types'
106
106
 
107
+ // System prompts (type + normaliser used by adapters)
108
+ export type { SystemPrompt, NormalizedSystemPrompt } from './system-prompts'
109
+ export { normalizeSystemPrompts } from './system-prompts'
110
+
107
111
  // Utility functions
108
112
  export { detectImageMimeType } from './utils'
109
113
 
@@ -372,9 +372,29 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
372
372
  state.assistantTextBufferTruncated = false
373
373
 
374
374
  if (captureContent) {
375
+ const systemPromptContents = config.systemPrompts.map((p) =>
376
+ typeof p === 'string' ? p : p.content,
377
+ )
378
+ // Anthropic prompt-caching users need to know which prompt carried
379
+ // `cache_control`: it's the one attribute that explains cache
380
+ // hit/miss in observability. Serialise per-prompt metadata as a
381
+ // single JSON span attribute so backends that don't understand
382
+ // GenAI events can still surface it. Kept off span events to
383
+ // avoid breaking the one-event-per-message GenAI semconv contract.
384
+ const systemPromptMetadata = config.systemPrompts.map((p) =>
385
+ typeof p === 'string' || p.metadata === undefined
386
+ ? null
387
+ : p.metadata,
388
+ )
389
+ if (systemPromptMetadata.some((m) => m !== null)) {
390
+ iterSpan.setAttribute(
391
+ 'tanstack.ai.system_prompt.metadata',
392
+ JSON.stringify(systemPromptMetadata),
393
+ )
394
+ }
375
395
  // Span events follow the original GenAI semconv (one event per
376
396
  // message). Backends that read events get content this way.
377
- for (const sys of config.systemPrompts) {
397
+ for (const sys of systemPromptContents) {
378
398
  iterSpan.addEvent('gen_ai.system.message', {
379
399
  content: redactContent(sys),
380
400
  })
@@ -391,7 +411,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware {
391
411
  // (`gen_ai.input.messages`) — backends like PostHog read prompt
392
412
  // content from this attribute, not from span events.
393
413
  const inputMessages: Array<{ role: string; content: string }> = []
394
- for (const sys of config.systemPrompts) {
414
+ for (const sys of systemPromptContents) {
395
415
  inputMessages.push({
396
416
  role: 'system',
397
417
  content: redactContent(sys),
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A single entry in `chat({ systemPrompts: [...] })`.
3
+ *
4
+ * Accepts a plain string (the common case) or a structured object that lets
5
+ * providers attach typed metadata to the prompt — e.g. Anthropic
6
+ * `cache_control` for prompt caching, future per-prompt safety overrides for
7
+ * Gemini, etc.
8
+ *
9
+ * At the chat call site, `metadata` is narrowed by the adapter via
10
+ * `~types['systemPromptMetadata']`. Providers that don't declare one inherit
11
+ * the default `never`, which makes the field carry no meaningful value: TS
12
+ * only accepts `undefined` there, and provider-foreign metadata that reaches
13
+ * an adapter via JS / `as any` is silently dropped, never written to the
14
+ * wire. For type-safe per-provider metadata, refer to the provider's
15
+ * `<Provider>SystemPromptMetadata` interface (e.g. `AnthropicSystemPromptMetadata`).
16
+ *
17
+ * @example
18
+ * // The 90% case — plain strings work everywhere.
19
+ * systemPrompts: ['Be concise.', 'Cite sources.']
20
+ *
21
+ * @example
22
+ * // Provider-specific metadata via the object form. No `satisfies` cast
23
+ * // is needed — the adapter narrows the `metadata` field's type at the
24
+ * // call site so users get autocomplete and structural checking
25
+ * // automatically.
26
+ * import { anthropicText } from '@tanstack/ai-anthropic'
27
+ *
28
+ * chat({
29
+ * adapter: anthropicText(),
30
+ * systemPrompts: [
31
+ * {
32
+ * content: 'Stable instructions — cache me.',
33
+ * metadata: { cache_control: { type: 'ephemeral' } },
34
+ * },
35
+ * 'Volatile per-request instruction.',
36
+ * ],
37
+ * })
38
+ */
39
+ export type SystemPrompt<TMetadata = unknown> =
40
+ | string
41
+ | {
42
+ content: string
43
+ metadata?: TMetadata
44
+ }
45
+
46
+ /**
47
+ * Normalised shape adapters see after the chat layer turns string entries
48
+ * into `{ content }` objects. Adapters call `normalizeSystemPrompts` once at
49
+ * the top of their option-mapping pipeline so the rest of the code only has
50
+ * to handle one shape.
51
+ */
52
+ export interface NormalizedSystemPrompt<TMetadata = unknown> {
53
+ content: string
54
+ metadata?: TMetadata
55
+ }
56
+
57
+ /**
58
+ * Normalise the public `systemPrompts` shape (`Array<string | { content, metadata? }>`)
59
+ * to a homogenous `Array<{ content, metadata? }>`. Adapters use this so they
60
+ * don't have to type-narrow string vs object inline.
61
+ *
62
+ * Returns an empty array (never `undefined`) so callers can chain `.map` /
63
+ * `.join` without an extra null check.
64
+ *
65
+ * Throws a `TypeError` (naming the offending index) if an object-form entry's
66
+ * `content` isn't a string. Public API boundary — callers reaching this
67
+ * function through `as any` / external JS would otherwise stream a literal
68
+ * `"undefined"` into the model's system prompt with no signal.
69
+ */
70
+ export function normalizeSystemPrompts<TMetadata = unknown>(
71
+ // Accept the wide public shape (`SystemPrompt<unknown>`) regardless of the
72
+ // caller's `TMetadata`. Adapters know their own metadata shape; the
73
+ // generic narrows the *output* so adapter code can read `p.metadata.X`
74
+ // without an additional cast.
75
+ prompts: ReadonlyArray<SystemPrompt> | undefined,
76
+ ): Array<NormalizedSystemPrompt<TMetadata>> {
77
+ if (!prompts || prompts.length === 0) return []
78
+ return prompts.map((p, i) => {
79
+ if (typeof p === 'string') return { content: p }
80
+ // Defence in depth: TypeScript narrows `p` to the object arm here, but
81
+ // this function is a public API boundary that callers can reach via
82
+ // plain JS or `as any`. Re-validate at runtime so we never stream a
83
+ // literal `"undefined"` into the model.
84
+ const candidate = p as unknown
85
+ if (candidate === null || typeof candidate !== 'object') {
86
+ throw new TypeError(
87
+ `systemPrompts[${i}]: expected a string or { content, metadata? }, got ${candidate === null ? 'null' : typeof candidate}`,
88
+ )
89
+ }
90
+ const { content } = candidate as { content?: unknown }
91
+ if (typeof content !== 'string') {
92
+ throw new TypeError(
93
+ `systemPrompts[${i}]: content must be a string, got ${typeof content}`,
94
+ )
95
+ }
96
+ return p as NormalizedSystemPrompt<TMetadata>
97
+ })
98
+ }
package/src/types.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  StandardSchemaV1,
4
4
  } from '@standard-schema/spec'
5
5
  import type { InternalLogger } from './logger/internal-logger'
6
+ import type { SystemPrompt } from './system-prompts'
6
7
  import type {
7
8
  BaseEvent as AGUIBaseEvent,
8
9
  CustomEvent as AGUICustomEvent,
@@ -729,7 +730,21 @@ export interface TextOptions<
729
730
  model: string
730
731
  messages: Array<ModelMessage>
731
732
  tools?: Array<Tool<any, any, any>>
732
- systemPrompts?: Array<string>
733
+ /**
734
+ * System prompts to include with the request.
735
+ *
736
+ * Accepts plain strings (the common case) or `{ content, metadata }`
737
+ * objects that let providers attach typed metadata (e.g. Anthropic
738
+ * `cache_control` for prompt caching) per prompt. At the chat call site
739
+ * the adapter narrows `metadata`'s type via `~types['systemPromptMetadata']`
740
+ * — providers that don't declare one default to `never`, which makes the
741
+ * field carry no meaningful value (TypeScript will only accept
742
+ * `undefined` there). Provider-foreign metadata that reaches an adapter
743
+ * via JS / `as any` is silently dropped, never written to the wire.
744
+ *
745
+ * @see SystemPrompt
746
+ */
747
+ systemPrompts?: Array<SystemPrompt>
733
748
  agentLoopStrategy?: AgentLoopStrategy
734
749
  /**
735
750
  * Controls the randomness of the output.
@@ -1146,10 +1161,9 @@ export interface CustomEvent extends AGUICustomEvent {
1146
1161
  * }
1147
1162
  * ```
1148
1163
  */
1149
- export interface StructuredOutputCompleteEvent<T = unknown> extends Omit<
1150
- CustomEvent,
1151
- 'name' | 'value'
1152
- > {
1164
+ export interface StructuredOutputCompleteEvent<
1165
+ T = unknown,
1166
+ > extends CustomEvent {
1153
1167
  name: 'structured-output.complete'
1154
1168
  value: { object: T; raw: string; reasoning?: string }
1155
1169
  }
@@ -1162,10 +1176,7 @@ export interface StructuredOutputCompleteEvent<T = unknown> extends Omit<
1162
1176
  * `messageId` the deltas will be tagged with so the routing decision can be
1163
1177
  * made per-message rather than globally.
1164
1178
  */
1165
- export interface StructuredOutputStartEvent extends Omit<
1166
- CustomEvent,
1167
- 'name' | 'value'
1168
- > {
1179
+ export interface StructuredOutputStartEvent extends CustomEvent {
1169
1180
  name: 'structured-output.start'
1170
1181
  value: { messageId: string }
1171
1182
  }
@@ -1177,10 +1188,7 @@ export interface StructuredOutputStartEvent extends Omit<
1177
1188
  * (the agent-loop branch of `runStreamingStructuredOutputImpl` in
1178
1189
  * `activities/chat/index.ts` forwards CUSTOM events from `TextEngine.run()`).
1179
1190
  */
1180
- export interface ApprovalRequestedEvent extends Omit<
1181
- CustomEvent,
1182
- 'name' | 'value'
1183
- > {
1191
+ export interface ApprovalRequestedEvent extends CustomEvent {
1184
1192
  name: 'approval-requested'
1185
1193
  value: {
1186
1194
  toolCallId: string
@@ -1196,10 +1204,7 @@ export interface ApprovalRequestedEvent extends Omit<
1196
1204
  * will not fire for that run. Shape fixed by the agent-loop forwarding in
1197
1205
  * `runStreamingStructuredOutputImpl` in `activities/chat/index.ts`.
1198
1206
  */
1199
- export interface ToolInputAvailableEvent extends Omit<
1200
- CustomEvent,
1201
- 'name' | 'value'
1202
- > {
1207
+ export interface ToolInputAvailableEvent extends CustomEvent {
1203
1208
  name: 'tool-input-available'
1204
1209
  value: {
1205
1210
  toolCallId: string