Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
239e721
chore(monorepo): tighten build/CI scope, defensive changeset ignores,…
AlemTuzlak May 18, 2026
2ab32d9
chore(tsconfig): consolidate to root tsconfig.base.json and include t…
AlemTuzlak May 18, 2026
77c947b
refactor(ai-gemini): merge GeminiThinking{,Advanced}Options into one …
AlemTuzlak May 18, 2026
66737a5
refactor(ai-anthropic): drop modelOptions.system escape hatch
AlemTuzlak May 18, 2026
d618ed4
fix(ai): expose Logger via the adapter-internals subpath
AlemTuzlak May 18, 2026
31bfa1c
test: fix type rot and unsafe casts surfaced by typecheck standardiza…
AlemTuzlak May 18, 2026
b5d1747
docs(contributing): add CONTRIBUTING.md, editorconfig, vscode recomme…
AlemTuzlak May 18, 2026
2f77fb7
ci: apply automated fixes
autofix-ci[bot] May 18, 2026
0561426
chore(changesets): use ts-* glob instead of listing each example expl…
AlemTuzlak May 18, 2026
30e3317
docs: drop incorrect Node.js v24+ requirement from READMEs and CONTRI…
AlemTuzlak May 18, 2026
8323a3c
fix: scope Node version constraint to ai-isolate-node (Node >= 22 for…
AlemTuzlak May 18, 2026
05e65f1
chore: address CodeRabbit feedback on PR #572
AlemTuzlak May 18, 2026
8dc3dd7
feat(ai): systemPrompts accept { content, metadata } with adapter-inf…
AlemTuzlak May 18, 2026
df6f675
ci: apply automated fixes
autofix-ci[bot] May 18, 2026
4bd5a60
Merge origin/main into feat/system-prompts-metadata
AlemTuzlak May 19, 2026
a5aaa6a
Merge remote-tracking branch 'origin/main' into feat/system-prompts-m…
AlemTuzlak May 19, 2026
0e94d74
address PR #575 review feedback
AlemTuzlak May 19, 2026
7a58420
ci: apply automated fixes
autofix-ci[bot] May 19, 2026
c3a480a
fix CI: ai-event-client cross-package import + system-prompts lint
AlemTuzlak May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .changeset/feat-system-prompts-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
---
'@tanstack/ai': minor
'@tanstack/ai-anthropic': minor
'@tanstack/ai-event-client': patch
'@tanstack/ai-gemini': patch
'@tanstack/ai-ollama': patch
'@tanstack/ai-openai': patch
'@tanstack/ai-openrouter': patch
'@tanstack/openai-base': patch
---

feat(ai): `systemPrompts` accept `{ content, metadata }` with adapter-inferred metadata typing

`chat({ systemPrompts })` now accepts either a plain string (the existing
shape — fully backward compatible) or `{ content, metadata }`. The `metadata`
field's type is inferred from the adapter via a new
`TSystemPromptMetadata` generic on `TextAdapter` / `BaseTextAdapter`:

- `@tanstack/ai-anthropic` declares `AnthropicSystemPromptMetadata` →
users get `cache_control` autocomplete and type-checking on
`systemPrompts[i].metadata` for Anthropic chats.
- Adapters with no per-prompt metadata (OpenAI, Gemini, Ollama,
OpenRouter, openai-base) inherit the default `never`, which means the
`metadata` field carries no meaningful value at the call site —
TypeScript only accepts `undefined` there. Provider-foreign metadata
that reaches an adapter via JS / `as any` is silently dropped, never
written to the wire.

```ts
import { chat } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'

// Anthropic — `cache_control` is autocompleted, no `satisfies` needed.
chat({
adapter: anthropicText({ apiKey }, 'claude-sonnet-4-6'),
systemPrompts: [
{
content: 'Stable instructions — cache me.',
metadata: { cache_control: { type: 'ephemeral' } },
},
'Volatile per-request instruction.',
],
})

// OpenAI — `metadata` is `never`; only `undefined` is assignable, so the
// field is effectively unusable. The object form without `metadata` still
// works for portability.
chat({
adapter: openaiText({ apiKey }, 'gpt-4o-mini'),
systemPrompts: [
'Plain string.',
{ content: 'Object form without metadata is allowed.' },
],
})
```

New exports:

- `@tanstack/ai`: `SystemPrompt`, `NormalizedSystemPrompt` types and the
`normalizeSystemPrompts()` helper adapters use to normalize the wide
input shape to `{ content, metadata? }` before consumption.
- `@tanstack/ai-anthropic`: `AnthropicSystemPromptMetadata` interface
(currently exposes `cache_control` for prompt caching).

Internal:

- New `TSystemPromptMetadata = never` generic on `TextAdapter` /
`BaseTextAdapter`, surfaced via `'~types'['systemPromptMetadata']`
for inference at the `chat()` call site.
- Anthropic adapter reads `metadata.cache_control` and attaches it to
the corresponding `TextBlockParam`.
- All other text adapters call `normalizeSystemPrompts()` and join
`.content` for their respective `instructions` / `system` /
`systemInstruction` fields. Foreign metadata that reaches them via JS
/ `as any` is dropped (never written to the wire).
- `normalizeSystemPrompts()` is the public API boundary and throws
`TypeError` (naming the offending index) for object-form entries whose
`content` isn't a string — preventing literal `"undefined"` from
reaching the model on stale call sites.
- OpenTelemetry middleware attaches per-prompt metadata as the
`tanstack.ai.system_prompt.metadata` JSON span attribute when
`captureContent: true` and at least one entry carries metadata, so
observability backends can distinguish cache hit/miss for Anthropic.
- `@tanstack/ai-event-client` mirrors the `SystemPrompt` shape locally
(avoids a circular import) and projects metadata away on the devtools
wire — devtools UI still receives `Array<string>`.
29 changes: 23 additions & 6 deletions packages/typescript/ai-anthropic/src/adapters/text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EventType } from '@tanstack/ai'
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
import { validateTextProviderOptions } from '../text/text-provider-options'
Expand Down Expand Up @@ -38,6 +38,7 @@ import type {
TextOptions,
} from '@tanstack/ai'
import type {
AnthropicSystemPromptMetadata,
ExternalTextProviderOptions,
InternalTextProviderOptions,
} from '../text/text-provider-options'
Expand Down Expand Up @@ -115,7 +116,12 @@ export class AnthropicTextAdapter<
TProviderOptions,
TInputModalities,
AnthropicMessageMetadataByModality,
TToolCapabilities
TToolCapabilities,
// TToolCallMetadata — anthropic has no tool-call metadata round-tripping
unknown,
// TSystemPromptMetadata — narrows `systemPrompts[i].metadata` at the
// chat() call site so users get `cache_control` autocomplete.
AnthropicSystemPromptMetadata
> {
readonly kind = 'text' as const
readonly name = 'anthropic' as const
Expand Down Expand Up @@ -356,11 +362,22 @@ export class AnthropicTextAdapter<
temperature: options.temperature,
top_p: options.topP,
messages: formattedMessages,
system: options.systemPrompts?.length
? options.systemPrompts.map(
(text): TextBlockParam => ({ type: 'text', text }),
system: (() => {
const normalized =
normalizeSystemPrompts<AnthropicSystemPromptMetadata>(
options.systemPrompts,
)
: undefined,
if (normalized.length === 0) return undefined
return normalized.map(
(p): TextBlockParam => ({
type: 'text',
text: p.content,
...(p.metadata?.cache_control && {
cache_control: p.metadata.cache_control,
}),
}),
)
})(),
tools: tools,
...validProviderOptions,
}
Expand Down
1 change: 1 addition & 0 deletions packages/typescript/ai-anthropic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
type AnthropicTextConfig,
type AnthropicTextProviderOptions,
} from './adapters/text'
export type { AnthropicSystemPromptMetadata } from './text/text-provider-options'

// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter
export {
Expand Down
31 changes: 31 additions & 0 deletions packages/typescript/ai-anthropic/src/text/text-provider-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,43 @@ import type {
BetaToolChoiceAuto,
BetaToolChoiceTool,
} from '@anthropic-ai/sdk/resources/beta/messages/messages'
import type { CacheControlEphemeral } from '@anthropic-ai/sdk/resources'
import type { AnthropicTool } from '../tools'
import type {
MessageParam,
TextBlockParam,
} from '@anthropic-ai/sdk/resources/messages'

/**
* Per-prompt metadata Anthropic understands on `systemPrompts` entries.
*
* Used via the structured form of `systemPrompts`:
*
* @example
* import type { AnthropicSystemPromptMetadata } from '@tanstack/ai-anthropic'
*
* chat({
* adapter: anthropicText(),
* model: 'claude-sonnet-4-6',
* systemPrompts: [
* {
* content: 'Stable instructions — cache me.',
* metadata: { cache_control: { type: 'ephemeral' } } satisfies AnthropicSystemPromptMetadata,
* },
* 'Volatile per-request instruction.',
* ],
* })
*/
export interface AnthropicSystemPromptMetadata {
/**
* Anthropic prompt-caching control applied to this system prompt's
* `TextBlockParam`.
*
* @see https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
*/
cache_control?: CacheControlEphemeral
}

export interface AnthropicContainerOptions {
/**
* Container identifier for reuse across requests.
Expand Down
67 changes: 62 additions & 5 deletions packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,64 @@ describe('Anthropic adapter option mapping', () => {
])
})

it('attaches cache_control to system TextBlockParams via systemPrompts metadata', async () => {
const mockStream = (async function* () {
yield {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' },
}
yield {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'ok' },
}
yield {
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 1 },
}
yield { type: 'message_stop' }
})()

mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)

const adapter = createAdapter('claude-3-7-sonnet')

for await (const _ of chat({
adapter,
messages: [{ role: 'user', content: 'Hi' }],
systemPrompts: [
{
content: 'Stable instructions — cache me.',
// metadata is narrowed to AnthropicSystemPromptMetadata via the
// adapter's `~types['systemPromptMetadata']` declaration — no
// `satisfies` needed.
metadata: { cache_control: { type: 'ephemeral', ttl: '5m' } },
},
'Volatile per-request instruction.',
],
})) {
// consume stream
}

const [payload] = mocks.betaMessagesCreate.mock.calls[0]!

// Object-form prompts attach their metadata cache_control; plain strings
// produce a TextBlockParam with no cache_control.
expect(payload.system).toEqual([
{
type: 'text',
text: 'Stable instructions — cache me.',
cache_control: { type: 'ephemeral', ttl: '5m' },
},
{
type: 'text',
text: 'Volatile per-request instruction.',
},
])
})

it('drops unknown modelOptions keys (e.g. `system`) and warns via logger.error', async () => {
const mockStream = (async function* () {
yield {
Expand All @@ -127,12 +185,12 @@ describe('Anthropic adapter option mapping', () => {
yield {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text: 'Hello' },
delta: { type: 'text_delta', text: 'ok' },
}
yield {
type: 'message_delta',
delta: { stop_reason: 'end_turn' },
usage: { output_tokens: 3 },
usage: { output_tokens: 1 },
}
yield { type: 'message_stop' }
})()
Expand All @@ -148,8 +206,7 @@ describe('Anthropic adapter option mapping', () => {
error: vi.fn(),
}

const chunks: StreamChunk[] = []
for await (const chunk of chat({
for await (const _ of chat({
adapter,
messages: [{ role: 'user', content: 'Hi' }],
systemPrompts: ['real system prompt'],
Expand All @@ -159,7 +216,7 @@ describe('Anthropic adapter option mapping', () => {
} as unknown as AnthropicTextProviderOptions,
debug: { logger, errors: true },
})) {
chunks.push(chunk)
// consume stream
}

const [payload] = mocks.betaMessagesCreate.mock.calls[0]!
Expand Down
17 changes: 15 additions & 2 deletions packages/typescript/ai-event-client/src/devtools-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,21 @@ interface DevtoolsModelMessage {
toolCalls?: unknown
}

/**
* Mirrors `SystemPrompt` from `@tanstack/ai` structurally so this package
* doesn't import from `@tanstack/ai` (which would introduce a circular dep,
* see file-top comment).
*/
type DevtoolsSystemPrompt = string | { content: string; metadata?: unknown }

interface DevtoolsMiddlewareContext {
requestId: string
streamId: string
conversationId?: string
provider: string
model: string
source: 'client' | 'server'
systemPrompts: Array<string>
systemPrompts: ReadonlyArray<DevtoolsSystemPrompt>
toolNames?: Array<string>
options?: Record<string, unknown>
modelOptions?: Record<string, unknown>
Expand Down Expand Up @@ -104,7 +111,13 @@ function buildEventContext(ctx: DevtoolsMiddlewareContext) {
model: ctx.model,
clientId: ctx.conversationId,
source: ctx.source,
systemPrompts: ctx.systemPrompts.length > 0 ? ctx.systemPrompts : undefined,
// Devtools wire payload is plain strings; per-prompt metadata is
// irrelevant for observation and would require devtools-UI changes to
// render. Project metadata away here so the wire shape is unchanged.
systemPrompts:
ctx.systemPrompts.length > 0
? ctx.systemPrompts.map((p) => (typeof p === 'string' ? p : p.content))
: undefined,
toolNames: ctx.toolNames,
options: ctx.options,
modelOptions: ctx.modelOptions,
Expand Down
9 changes: 7 additions & 2 deletions packages/typescript/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { FinishReason } from '@google/genai'
import { EventType } from '@tanstack/ai'
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
import { BaseTextAdapter } from '@tanstack/ai/adapters'
import { convertToolsToProviderFormat } from '../tools/tool-converter'
import {
Expand Down Expand Up @@ -838,7 +838,12 @@ export class GeminiTextAdapter<
: undefined,
}
: undefined,
systemInstruction: options.systemPrompts?.join('\n'),
systemInstruction: (() => {
const prompts = normalizeSystemPrompts(options.systemPrompts)
return prompts.length > 0
? prompts.map((p) => p.content).join('\n')
: undefined
})(),
tools: convertToolsToProviderFormat(options.tools),
},
}
Expand Down
Loading
Loading