@tanstack/ai 0.19.1 → 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.
@@ -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.