diff --git a/.changeset/feat-system-prompts-metadata.md b/.changeset/feat-system-prompts-metadata.md new file mode 100644 index 000000000..21db0cf71 --- /dev/null +++ b/.changeset/feat-system-prompts-metadata.md @@ -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`. diff --git a/packages/typescript/ai-anthropic/src/adapters/text.ts b/packages/typescript/ai-anthropic/src/adapters/text.ts index c541f17b9..635f40b1a 100644 --- a/packages/typescript/ai-anthropic/src/adapters/text.ts +++ b/packages/typescript/ai-anthropic/src/adapters/text.ts @@ -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' @@ -38,6 +38,7 @@ import type { TextOptions, } from '@tanstack/ai' import type { + AnthropicSystemPromptMetadata, ExternalTextProviderOptions, InternalTextProviderOptions, } from '../text/text-provider-options' @@ -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 @@ -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( + 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, } diff --git a/packages/typescript/ai-anthropic/src/index.ts b/packages/typescript/ai-anthropic/src/index.ts index 1ba8e92b8..cc1315947 100644 --- a/packages/typescript/ai-anthropic/src/index.ts +++ b/packages/typescript/ai-anthropic/src/index.ts @@ -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 { diff --git a/packages/typescript/ai-anthropic/src/text/text-provider-options.ts b/packages/typescript/ai-anthropic/src/text/text-provider-options.ts index 975f29240..ed4f05746 100644 --- a/packages/typescript/ai-anthropic/src/text/text-provider-options.ts +++ b/packages/typescript/ai-anthropic/src/text/text-provider-options.ts @@ -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. diff --git a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts index f201c27d5..eddb782d5 100644 --- a/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts @@ -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 { @@ -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' } })() @@ -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'], @@ -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]! diff --git a/packages/typescript/ai-event-client/src/devtools-middleware.ts b/packages/typescript/ai-event-client/src/devtools-middleware.ts index e574a92c8..37ade008d 100644 --- a/packages/typescript/ai-event-client/src/devtools-middleware.ts +++ b/packages/typescript/ai-event-client/src/devtools-middleware.ts @@ -12,6 +12,13 @@ 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 @@ -19,7 +26,7 @@ interface DevtoolsMiddlewareContext { provider: string model: string source: 'client' | 'server' - systemPrompts: Array + systemPrompts: ReadonlyArray toolNames?: Array options?: Record modelOptions?: Record @@ -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, diff --git a/packages/typescript/ai-gemini/src/adapters/text.ts b/packages/typescript/ai-gemini/src/adapters/text.ts index 244b0bfe9..6e751a2ad 100644 --- a/packages/typescript/ai-gemini/src/adapters/text.ts +++ b/packages/typescript/ai-gemini/src/adapters/text.ts @@ -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 { @@ -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), }, } diff --git a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts index 9f4a574e5..c30059fcc 100644 --- a/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts +++ b/packages/typescript/ai-gemini/tests/gemini-adapter.test.ts @@ -131,6 +131,51 @@ describe('GeminiAdapter through AI', () => { ]) }) + it('joins object-form systemPrompts into systemInstruction and drops foreign metadata', async () => { + const streamChunks = [ + { + candidates: [ + { + content: { parts: [{ text: 'ok' }] }, + finishReason: 'STOP', + }, + ], + usageMetadata: { totalTokenCount: 1 }, + }, + ] + + mocks.generateContentStreamSpy.mockResolvedValue(createStream(streamChunks)) + + const adapter = createTextAdapter() + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + systemPrompts: [ + 'plain', + { content: 'object-form' }, + // `metadata` on a Gemini chat is `never` at the type level; the cast + // models a stale call site reaching the adapter via JS / `as any`. + // The adapter must still produce the expected systemInstruction + // string and never leak the foreign field anywhere on the payload. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { + content: 'with-foreign-meta', + metadata: { cache_control: {} } as any, + }, + ], + })) { + /* consume stream */ + } + + const [payload] = mocks.generateContentStreamSpy.mock.calls[0]! + expect(payload.config.systemInstruction).toBe( + 'plain\nobject-form\nwith-foreign-meta', + ) + // Foreign metadata never reaches the wire. + expect(JSON.stringify(payload)).not.toContain('cache_control') + }) + it('maps every common and provider option into the Gemini payload', async () => { const streamChunks = [ { diff --git a/packages/typescript/ai-ollama/src/adapters/text.ts b/packages/typescript/ai-ollama/src/adapters/text.ts index de67717d4..befce0d41 100644 --- a/packages/typescript/ai-ollama/src/adapters/text.ts +++ b/packages/typescript/ai-ollama/src/adapters/text.ts @@ -1,4 +1,4 @@ -import { EventType } from '@tanstack/ai' +import { EventType, normalizeSystemPrompts } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { createOllamaClient, generateId, getOllamaHostFromEnv } from '../utils' @@ -559,10 +559,11 @@ export class OllamaTextAdapter extends BaseTextAdapter< const formattedMessages = this.formatMessages(options.messages) - if (options.systemPrompts?.length) { + const prompts = normalizeSystemPrompts(options.systemPrompts) + if (prompts.length > 0) { formattedMessages.unshift({ role: 'system', - content: options.systemPrompts.join('\n'), + content: prompts.map((p) => p.content).join('\n'), }) } diff --git a/packages/typescript/ai-ollama/tests/text-adapter.test.ts b/packages/typescript/ai-ollama/tests/text-adapter.test.ts index f2ece1065..6bdb0222e 100644 --- a/packages/typescript/ai-ollama/tests/text-adapter.test.ts +++ b/packages/typescript/ai-ollama/tests/text-adapter.test.ts @@ -327,3 +327,79 @@ describe('OllamaTextAdapter.structuredOutput', () => { ).rejects.toThrow(/Structured output generation failed.*network down/) }) }) + +describe('OllamaTextAdapter system prompts', () => { + it('prepends mixed string + object-form systemPrompts as a single role:system message and drops foreign metadata', async () => { + chatMock.mockResolvedValueOnce( + asyncIterable([ + { + message: { role: 'assistant', content: 'ok' }, + done: true, + done_reason: 'stop', + }, + ]), + ) + + const adapter = createOllamaChat('llama3.2') + await collectStream( + adapter.chatStream({ + logger: testLogger, + model: 'llama3.2', + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: [ + 'plain', + { content: 'object-form' }, + // `metadata` on an Ollama chat is `never` at the type level; the + // cast models a stale call site reaching the adapter via JS / `as + // any`. The adapter must still join the content correctly and + // never leak the foreign field onto the wire. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { content: 'with-meta', metadata: { cache_control: {} } as any }, + ], + }), + ) + + expect(chatMock).toHaveBeenCalledTimes(1) + const [payload] = chatMock.mock.calls[0]! + const messages = payload.messages as Array<{ + role: string + content: string + }> + + // System prompt is the first message and joins all content with '\n'. + expect(messages[0]).toEqual({ + role: 'system', + content: 'plain\nobject-form\nwith-meta', + }) + expect(messages[1]).toMatchObject({ role: 'user' }) + // Foreign metadata never reaches the wire. + expect(JSON.stringify(payload)).not.toContain('cache_control') + }) + + it('omits the system message entirely when systemPrompts is empty or undefined', async () => { + chatMock.mockResolvedValueOnce( + asyncIterable([ + { + message: { role: 'assistant', content: 'ok' }, + done: true, + done_reason: 'stop', + }, + ]), + ) + + const adapter = createOllamaChat('llama3.2') + await collectStream( + adapter.chatStream({ + logger: testLogger, + model: 'llama3.2', + messages: [{ role: 'user', content: 'hi' }], + }), + ) + + const [payload] = chatMock.mock.calls[0]! + const roles = (payload.messages as Array<{ role: string }>).map( + (m) => m.role, + ) + expect(roles).not.toContain('system') + }) +}) diff --git a/packages/typescript/ai-openai/src/adapters/text.ts b/packages/typescript/ai-openai/src/adapters/text.ts index a17b95267..d1d0f94ce 100644 --- a/packages/typescript/ai-openai/src/adapters/text.ts +++ b/packages/typescript/ai-openai/src/adapters/text.ts @@ -1,4 +1,5 @@ import OpenAI from 'openai' +import { normalizeSystemPrompts } from '@tanstack/ai' import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base' import { validateTextProviderOptions } from '../text/text-provider-options' import { convertToolsToProviderFormat } from '../tools' @@ -140,10 +141,11 @@ export class OpenAITextAdapter< }), ...(options.topP !== undefined && { top_p: options.topP }), ...(options.metadata !== undefined && { metadata: options.metadata }), - ...(options.systemPrompts && - options.systemPrompts.length > 0 && { - instructions: options.systemPrompts.join('\n'), - }), + ...(() => { + const prompts = normalizeSystemPrompts(options.systemPrompts) + if (prompts.length === 0) return {} + return { instructions: prompts.map((p) => p.content).join('\n') } + })(), input, ...(tools && tools.length > 0 && { tools }), } diff --git a/packages/typescript/ai-openai/tests/openai-adapter.test.ts b/packages/typescript/ai-openai/tests/openai-adapter.test.ts index 8154bcad7..90b805c8a 100644 --- a/packages/typescript/ai-openai/tests/openai-adapter.test.ts +++ b/packages/typescript/ai-openai/tests/openai-adapter.test.ts @@ -130,4 +130,47 @@ describe('OpenAI adapter option mapping', () => { expect(Array.isArray(payload.tools)).toBe(true) expect(payload.tools.length).toBeGreaterThan(0) }) + + it('accepts mixed string + object-form systemPrompts and joins .content into instructions', async () => { + const mockStream = createMockChatCompletionsStream([ + { + type: 'response.created', + response: { + id: 'resp-mixed', + model: 'gpt-4o-mini', + status: 'in_progress', + created_at: 1234567890, + }, + }, + { + type: 'response.completed', + response: { + id: 'resp-mixed', + status: 'completed', + usage: { input_tokens: 1, output_tokens: 0 }, + }, + }, + ]) + + const responsesCreate = vi.fn().mockResolvedValueOnce(mockStream) + const adapter = createAdapter('gpt-4o-mini') + ;(adapter as any).client = { responses: { create: responsesCreate } } + + for await (const _ of chat({ + adapter, + systemPrompts: [ + 'Plain string.', + // Object form (without metadata — OpenAI has no per-prompt metadata + // surface, so the `metadata` field is typed `never` and rejected + // at the call site). + { content: 'Structured.' }, + ], + messages: [{ role: 'user', content: 'Hi' }], + })) { + // consume stream + } + + const [payload] = responsesCreate.mock.calls[0]! + expect(payload.instructions).toBe('Plain string.\nStructured.') + }) }) diff --git a/packages/typescript/ai-openrouter/src/adapters/responses-text.ts b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts index 18d6bf2ab..82b396fa2 100644 --- a/packages/typescript/ai-openrouter/src/adapters/responses-text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/responses-text.ts @@ -1,5 +1,5 @@ import { OpenRouter } from '@openrouter/sdk' -import { EventType } from '@tanstack/ai' +import { EventType, normalizeSystemPrompts } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' @@ -1553,10 +1553,11 @@ export class OpenRouterResponsesTextAdapter< }), ...(options.topP !== undefined && { topP: options.topP }), ...(options.metadata !== undefined && { metadata: options.metadata }), - ...(options.systemPrompts && - options.systemPrompts.length > 0 && { - instructions: options.systemPrompts.join('\n'), - }), + ...(() => { + const prompts = normalizeSystemPrompts(options.systemPrompts) + if (prompts.length === 0) return {} + return { instructions: prompts.map((p) => p.content).join('\n') } + })(), input, ...(tools && tools.length > 0 && { diff --git a/packages/typescript/ai-openrouter/src/adapters/text.ts b/packages/typescript/ai-openrouter/src/adapters/text.ts index 6878d8e19..17f14df8e 100644 --- a/packages/typescript/ai-openrouter/src/adapters/text.ts +++ b/packages/typescript/ai-openrouter/src/adapters/text.ts @@ -1,5 +1,5 @@ import { OpenRouter } from '@openrouter/sdk' -import { EventType } from '@tanstack/ai' +import { EventType, normalizeSystemPrompts } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' @@ -1114,10 +1114,11 @@ export class OpenRouterTextAdapter< : '' const messages: Array = [] - if (options.systemPrompts?.length) { + const systemPrompts = normalizeSystemPrompts(options.systemPrompts) + if (systemPrompts.length > 0) { messages.push({ role: 'system', - content: options.systemPrompts.join('\n'), + content: systemPrompts.map((p) => p.content).join('\n'), }) } for (const m of options.messages) { diff --git a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts index 3fc2ba243..f1940e263 100644 --- a/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts +++ b/packages/typescript/ai-openrouter/tests/openrouter-adapter.test.ts @@ -173,6 +173,51 @@ describe('OpenRouter adapter option mapping', () => { expect(serialized).toHaveProperty('tool_choice', 'auto') }) + it('prepends mixed string + object-form systemPrompts as a role:system message and drops foreign metadata', async () => { + const streamChunks = [ + { + id: 'chatcmpl-sys', + model: 'openai/gpt-4o-mini', + choices: [{ delta: { content: 'ok' }, finishReason: 'stop' }], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + }, + ] + + setupMockSdkClient(streamChunks) + + const adapter = createAdapter() + + for await (const _ of chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: [ + 'plain', + { content: 'object-form' }, + // `metadata` is `never` for OpenRouter at the type level; the cast + // simulates a stale JS / `as any` caller. The adapter must still + // produce the joined system message and never leak the foreign + // field to the wire. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { content: 'with-meta', metadata: { cache_control: {} } as any }, + ], + })) { + /* consume */ + } + + const [rawParams] = mockSend.mock.calls[0]! + const params = rawParams.chatRequest + const messages = params.messages as Array<{ role: string; content: string }> + + // OpenRouter injects systemPrompts as a positional role:system message + // at the head of `messages` (not via a separate `system` field). + expect(messages[0]).toEqual({ + role: 'system', + content: 'plain\nobject-form\nwith-meta', + }) + expect(messages[1]).toMatchObject({ role: 'user' }) + expect(JSON.stringify(params)).not.toContain('cache_control') + }) + it('streams chat chunks with content and usage', async () => { const streamChunks = [ { diff --git a/packages/typescript/ai/src/activities/chat/adapter.ts b/packages/typescript/ai/src/activities/chat/adapter.ts index df1c94726..e4c4dccee 100644 --- a/packages/typescript/ai/src/activities/chat/adapter.ts +++ b/packages/typescript/ai/src/activities/chat/adapter.ts @@ -55,6 +55,10 @@ export interface StructuredOutputResult { * - TMessageMetadata: Metadata types for content parts (already resolved) * - TToolCapabilities: Tuple of tool-kind strings supported by this model, resolved from `supports.tools` * - TToolCallMetadata: Metadata type that round-trips with tool calls (e.g. Gemini's `thoughtSignature`) + * - TSystemPromptMetadata: Provider-typed metadata accepted on each + * `systemPrompts[i]` entry (e.g. Anthropic `cache_control`). Defaults to + * `never` — adapters without per-prompt metadata reject the `metadata` + * field at the call site. */ export interface TextAdapter< TModel extends string, @@ -63,6 +67,7 @@ export interface TextAdapter< TMessageMetadataByModality extends DefaultMessageMetadataByModality, TToolCapabilities extends ReadonlyArray = ReadonlyArray, TToolCallMetadata = unknown, + TSystemPromptMetadata = never, > { /** Discriminator for adapter kind */ readonly kind: 'text' @@ -80,6 +85,7 @@ export interface TextAdapter< messageMetadataByModality: TMessageMetadataByModality toolCapabilities: TToolCapabilities toolCallMetadata: TToolCallMetadata + systemPromptMetadata: TSystemPromptMetadata } /** @@ -123,7 +129,7 @@ export interface TextAdapter< * A TextAdapter with any/unknown type parameters. * Useful as a constraint in generic functions and interfaces. */ -export type AnyTextAdapter = TextAdapter +export type AnyTextAdapter = TextAdapter /** * Abstract base class for text adapters. @@ -138,13 +144,15 @@ export abstract class BaseTextAdapter< TMessageMetadataByModality extends DefaultMessageMetadataByModality, TToolCapabilities extends ReadonlyArray = ReadonlyArray, TToolCallMetadata = unknown, + TSystemPromptMetadata = never, > implements TextAdapter< TModel, TProviderOptions, TInputModalities, TMessageMetadataByModality, TToolCapabilities, - TToolCallMetadata + TToolCallMetadata, + TSystemPromptMetadata > { readonly kind = 'text' as const abstract readonly name: string @@ -157,6 +165,7 @@ export abstract class BaseTextAdapter< messageMetadataByModality: TMessageMetadataByModality toolCapabilities: TToolCapabilities toolCallMetadata: TToolCallMetadata + systemPromptMetadata: TSystemPromptMetadata } protected config: TextAdapterConfig diff --git a/packages/typescript/ai/src/activities/chat/index.ts b/packages/typescript/ai/src/activities/chat/index.ts index 356d86e2f..adf2b2f73 100644 --- a/packages/typescript/ai/src/activities/chat/index.ts +++ b/packages/typescript/ai/src/activities/chat/index.ts @@ -56,6 +56,7 @@ import type { ChatMiddlewareContext, ChatMiddlewarePhase, } from './middleware/types' +import type { SystemPrompt } from '../../system-prompts' import type { InternalLogger } from '../../logger/internal-logger' import type { DebugOption } from '../../logger/types' import type { ProviderTool } from '../../tools/provider-tool' @@ -102,8 +103,18 @@ export interface TextActivityOptions< messageMetadataByModality: TAdapter['~types']['messageMetadataByModality'] }> > - /** System prompts to prepend to the conversation */ - systemPrompts?: TextOptions['systemPrompts'] + /** + * System prompts to prepend to the conversation. + * + * Accepts plain strings or `{ content, metadata }` objects. The `metadata` + * field is typed by the adapter — Anthropic narrows it to + * `AnthropicSystemPromptMetadata` (with `cache_control` for prompt + * caching), providers without per-prompt metadata reject the field + * entirely. + */ + systemPrompts?: Array< + SystemPrompt + > /** * Tools for function calling (auto-executed when called). * @@ -275,7 +286,7 @@ interface TextEngineConfig< TParams extends TextOptions = TextOptions, > { adapter: TAdapter - systemPrompts?: Array + systemPrompts?: Array params: TParams middleware?: Array context?: unknown @@ -290,7 +301,7 @@ class TextEngine< > { private readonly adapter: TAdapter private params: TParams - private systemPrompts: Array + private systemPrompts: Array private tools: Array private readonly loopStrategy: AgentLoopStrategy private toolCallManager: ToolCallManager diff --git a/packages/typescript/ai/src/activities/chat/middleware/types.ts b/packages/typescript/ai/src/activities/chat/middleware/types.ts index c825a696b..434e6b339 100644 --- a/packages/typescript/ai/src/activities/chat/middleware/types.ts +++ b/packages/typescript/ai/src/activities/chat/middleware/types.ts @@ -1,4 +1,5 @@ import type { ModelMessage, StreamChunk, Tool, ToolCall } from '../../../types' +import type { SystemPrompt } from '../../../system-prompts' // =========================== // Middleware Context @@ -74,7 +75,7 @@ export interface ChatMiddlewareContext { // --- Config-derived info (may update per-iteration via onConfig) --- /** System prompts configured for this chat */ - systemPrompts: Array + systemPrompts: Array /** Names of configured tools, if any */ toolNames?: Array /** Flattened generation options (temperature, topP, maxTokens, metadata) */ @@ -115,7 +116,7 @@ export interface ChatMiddlewareContext { */ export interface ChatMiddlewareConfig { messages: Array - systemPrompts: Array + systemPrompts: Array tools: Array temperature?: number topP?: number diff --git a/packages/typescript/ai/src/index.ts b/packages/typescript/ai/src/index.ts index 8f7c677e4..dba60f076 100644 --- a/packages/typescript/ai/src/index.ts +++ b/packages/typescript/ai/src/index.ts @@ -104,6 +104,10 @@ export type { // All types export * from './types' +// System prompts (type + normaliser used by adapters) +export type { SystemPrompt, NormalizedSystemPrompt } from './system-prompts' +export { normalizeSystemPrompts } from './system-prompts' + // Utility functions export { detectImageMimeType } from './utils' diff --git a/packages/typescript/ai/src/middlewares/otel.ts b/packages/typescript/ai/src/middlewares/otel.ts index f4e39cd2f..028cb2dbc 100644 --- a/packages/typescript/ai/src/middlewares/otel.ts +++ b/packages/typescript/ai/src/middlewares/otel.ts @@ -372,9 +372,29 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { state.assistantTextBufferTruncated = false if (captureContent) { + const systemPromptContents = config.systemPrompts.map((p) => + typeof p === 'string' ? p : p.content, + ) + // Anthropic prompt-caching users need to know which prompt carried + // `cache_control`: it's the one attribute that explains cache + // hit/miss in observability. Serialise per-prompt metadata as a + // single JSON span attribute so backends that don't understand + // GenAI events can still surface it. Kept off span events to + // avoid breaking the one-event-per-message GenAI semconv contract. + const systemPromptMetadata = config.systemPrompts.map((p) => + typeof p === 'string' || p.metadata === undefined + ? null + : p.metadata, + ) + if (systemPromptMetadata.some((m) => m !== null)) { + iterSpan.setAttribute( + 'tanstack.ai.system_prompt.metadata', + JSON.stringify(systemPromptMetadata), + ) + } // Span events follow the original GenAI semconv (one event per // message). Backends that read events get content this way. - for (const sys of config.systemPrompts) { + for (const sys of systemPromptContents) { iterSpan.addEvent('gen_ai.system.message', { content: redactContent(sys), }) @@ -391,7 +411,7 @@ export function otelMiddleware(options: OtelMiddlewareOptions): ChatMiddleware { // (`gen_ai.input.messages`) — backends like PostHog read prompt // content from this attribute, not from span events. const inputMessages: Array<{ role: string; content: string }> = [] - for (const sys of config.systemPrompts) { + for (const sys of systemPromptContents) { inputMessages.push({ role: 'system', content: redactContent(sys), diff --git a/packages/typescript/ai/src/system-prompts.ts b/packages/typescript/ai/src/system-prompts.ts new file mode 100644 index 000000000..5db5f3038 --- /dev/null +++ b/packages/typescript/ai/src/system-prompts.ts @@ -0,0 +1,98 @@ +/** + * A single entry in `chat({ systemPrompts: [...] })`. + * + * Accepts a plain string (the common case) or a structured object that lets + * providers attach typed metadata to the prompt — e.g. Anthropic + * `cache_control` for prompt caching, future per-prompt safety overrides for + * Gemini, etc. + * + * At the chat call site, `metadata` is narrowed by the adapter via + * `~types['systemPromptMetadata']`. Providers that don't declare one inherit + * the default `never`, which makes the field carry no meaningful value: TS + * only accepts `undefined` there, and provider-foreign metadata that reaches + * an adapter via JS / `as any` is silently dropped, never written to the + * wire. For type-safe per-provider metadata, refer to the provider's + * `SystemPromptMetadata` interface (e.g. `AnthropicSystemPromptMetadata`). + * + * @example + * // The 90% case — plain strings work everywhere. + * systemPrompts: ['Be concise.', 'Cite sources.'] + * + * @example + * // Provider-specific metadata via the object form. No `satisfies` cast + * // is needed — the adapter narrows the `metadata` field's type at the + * // call site so users get autocomplete and structural checking + * // automatically. + * import { anthropicText } from '@tanstack/ai-anthropic' + * + * chat({ + * adapter: anthropicText(), + * systemPrompts: [ + * { + * content: 'Stable instructions — cache me.', + * metadata: { cache_control: { type: 'ephemeral' } }, + * }, + * 'Volatile per-request instruction.', + * ], + * }) + */ +export type SystemPrompt = + | string + | { + content: string + metadata?: TMetadata + } + +/** + * Normalised shape adapters see after the chat layer turns string entries + * into `{ content }` objects. Adapters call `normalizeSystemPrompts` once at + * the top of their option-mapping pipeline so the rest of the code only has + * to handle one shape. + */ +export interface NormalizedSystemPrompt { + content: string + metadata?: TMetadata +} + +/** + * Normalise the public `systemPrompts` shape (`Array`) + * to a homogenous `Array<{ content, metadata? }>`. Adapters use this so they + * don't have to type-narrow string vs object inline. + * + * Returns an empty array (never `undefined`) so callers can chain `.map` / + * `.join` without an extra null check. + * + * Throws a `TypeError` (naming the offending index) if an object-form entry's + * `content` isn't a string. Public API boundary — callers reaching this + * function through `as any` / external JS would otherwise stream a literal + * `"undefined"` into the model's system prompt with no signal. + */ +export function normalizeSystemPrompts( + // Accept the wide public shape (`SystemPrompt`) regardless of the + // caller's `TMetadata`. Adapters know their own metadata shape; the + // generic narrows the *output* so adapter code can read `p.metadata.X` + // without an additional cast. + prompts: ReadonlyArray | undefined, +): Array> { + if (!prompts || prompts.length === 0) return [] + return prompts.map((p, i) => { + if (typeof p === 'string') return { content: p } + // Defence in depth: TypeScript narrows `p` to the object arm here, but + // this function is a public API boundary that callers can reach via + // plain JS or `as any`. Re-validate at runtime so we never stream a + // literal `"undefined"` into the model. + const candidate = p as unknown + if (candidate === null || typeof candidate !== 'object') { + throw new TypeError( + `systemPrompts[${i}]: expected a string or { content, metadata? }, got ${candidate === null ? 'null' : typeof candidate}`, + ) + } + const { content } = candidate as { content?: unknown } + if (typeof content !== 'string') { + throw new TypeError( + `systemPrompts[${i}]: content must be a string, got ${typeof content}`, + ) + } + return p as NormalizedSystemPrompt + }) +} diff --git a/packages/typescript/ai/src/types.ts b/packages/typescript/ai/src/types.ts index 3ffe72ffe..a761c66f9 100644 --- a/packages/typescript/ai/src/types.ts +++ b/packages/typescript/ai/src/types.ts @@ -3,6 +3,7 @@ import type { StandardSchemaV1, } from '@standard-schema/spec' import type { InternalLogger } from './logger/internal-logger' +import type { SystemPrompt } from './system-prompts' import type { BaseEvent as AGUIBaseEvent, CustomEvent as AGUICustomEvent, @@ -729,7 +730,21 @@ export interface TextOptions< model: string messages: Array tools?: Array> - systemPrompts?: Array + /** + * System prompts to include with the request. + * + * Accepts plain strings (the common case) or `{ content, metadata }` + * objects that let providers attach typed metadata (e.g. Anthropic + * `cache_control` for prompt caching) per prompt. At the chat call site + * the adapter narrows `metadata`'s type via `~types['systemPromptMetadata']` + * — providers that don't declare one default to `never`, which makes the + * field carry no meaningful value (TypeScript will only accept + * `undefined` there). Provider-foreign metadata that reaches an adapter + * via JS / `as any` is silently dropped, never written to the wire. + * + * @see SystemPrompt + */ + systemPrompts?: Array agentLoopStrategy?: AgentLoopStrategy /** * Controls the randomness of the output. diff --git a/packages/typescript/ai/tests/chat-structured-output-stream.test.ts b/packages/typescript/ai/tests/chat-structured-output-stream.test.ts index 82e988aca..09a9149c2 100644 --- a/packages/typescript/ai/tests/chat-structured-output-stream.test.ts +++ b/packages/typescript/ai/tests/chat-structured-output-stream.test.ts @@ -60,6 +60,7 @@ function makeAdapter(opts: { }, toolCapabilities: [] as ReadonlyArray, toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, }, chatStream: () => (async function* () {})(), structuredOutput: diff --git a/packages/typescript/ai/tests/devtools-system-prompt-mirror.test.ts b/packages/typescript/ai/tests/devtools-system-prompt-mirror.test.ts new file mode 100644 index 000000000..17ed75070 --- /dev/null +++ b/packages/typescript/ai/tests/devtools-system-prompt-mirror.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expectTypeOf } from 'vitest' +import type { SystemPrompt } from '../src/system-prompts' + +/** + * `@tanstack/ai-event-client/src/devtools-middleware.ts` intentionally + * duplicates the `SystemPrompt` shape locally as `DevtoolsSystemPrompt` to + * avoid a circular import (`@tanstack/ai-event-client` is a runtime dep of + * `@tanstack/ai`, so the devtools middleware can't depend back on + * `@tanstack/ai`'s types directly). + * + * Re-declare the mirror here and assert structural equality against the + * canonical `SystemPrompt`. The test lives in `@tanstack/ai` rather than + * `@tanstack/ai-event-client` because the Nx project graph would resolve + * a `@tanstack/ai`-importing test in `ai-event-client` only via a circular + * `workspace:*` dev-dep, which we deliberately avoid. + * + * If `SystemPrompt` ever gains a third variant (or the existing shape + * changes), this guard fails at type-check time — forcing the maintainer + * to update both the source mirror in `devtools-middleware.ts` and this + * test mirror, rather than silently emitting `undefined` from the wire + * projection `typeof p === 'string' ? p : p.content`. + */ +type DevtoolsSystemPrompt = string | { content: string; metadata?: unknown } + +describe('DevtoolsSystemPrompt structural mirror of SystemPrompt', () => { + it('the local devtools mirror is mutually assignable with SystemPrompt', () => { + expectTypeOf().toExtend() + expectTypeOf().toExtend() + }) +}) diff --git a/packages/typescript/ai/tests/middleware.test.ts b/packages/typescript/ai/tests/middleware.test.ts index d0c2e8297..aeb5cc688 100644 --- a/packages/typescript/ai/tests/middleware.test.ts +++ b/packages/typescript/ai/tests/middleware.test.ts @@ -9,6 +9,7 @@ import { serverTool, } from './test-utils' import type { StreamChunk } from '../src/types' +import type { SystemPrompt } from '../src/system-prompts' import type { ChatMiddleware, ChatMiddlewareContext, @@ -189,6 +190,64 @@ describe('chat() middleware', () => { expect(calls[0]!.systemPrompts).toContain('Original') }) + it('should preserve object-form systemPrompts through middleware', async () => { + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('hi'), ev.runFinished('stop')], + ], + }) + + // Capture what the middleware sees in config.systemPrompts so we can + // assert the wide shape (Array) — not a flattened + // Array — reaches middleware code. + const observed: Array = [] + + // Middleware reads ChatMiddlewareConfig.systemPrompts (now widened to + // Array) and prepends another object-form entry. Only + // mutate at beforeModel so the assertion stays deterministic + // (onConfig fires at both init and beforeModel). + const middleware: ChatMiddleware = { + name: 'prepender', + onConfig: (ctx, config) => { + if (ctx.phase !== 'beforeModel') return undefined + observed.push(...config.systemPrompts) + return { + systemPrompts: [ + { content: 'prepended', metadata: { tag: 'mw' } }, + ...config.systemPrompts, + ], + } + }, + } + + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Hi' }], + systemPrompts: [ + 'plain-original', + { content: 'object-original', metadata: { caller: 'test' } }, + ], + middleware: [middleware], + }) + await collectChunks(stream as AsyncIterable) + + // The middleware saw the wide shape: a plain string and an object + // with metadata, not a pre-flattened Array. + expect(observed).toEqual([ + 'plain-original', + { content: 'object-original', metadata: { caller: 'test' } }, + ]) + + // The adapter receives the middleware's mutation verbatim — strings + // stay strings, object-form entries keep their metadata, and the + // prepend order is preserved. + expect(calls[0]!.systemPrompts).toEqual([ + { content: 'prepended', metadata: { tag: 'mw' } }, + 'plain-original', + { content: 'object-original', metadata: { caller: 'test' } }, + ]) + }) + it('should pipe config through multiple middlewares in order', async () => { const { adapter, calls } = createMockAdapter({ iterations: [ @@ -2576,7 +2635,7 @@ describe('chat() middleware', () => { phase: string iteration: number maxTokens?: number - systemPrompts: Array + systemPrompts: Array }> = [] const tool = serverTool('myTool', () => ({ ok: true })) diff --git a/packages/typescript/ai/tests/middlewares/otel.test.ts b/packages/typescript/ai/tests/middlewares/otel.test.ts index 433b77fc1..632d04b6f 100644 --- a/packages/typescript/ai/tests/middlewares/otel.test.ts +++ b/packages/typescript/ai/tests/middlewares/otel.test.ts @@ -435,6 +435,49 @@ describe('otelMiddleware — captureContent', () => { expect(asstEvt!.attributes!['content']).toBe('Hi [NUM] there') }) + it('captureContent=true attaches systemPrompt metadata as a JSON span attribute when any entry carries metadata', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer, captureContent: true }) + const ctx = makeCtx() + + await runToIterationStart(mw, ctx, { + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: [ + 'plain', + { + content: 'cached', + metadata: { cache_control: { type: 'ephemeral' } }, + }, + { content: 'no-meta' }, + ], + }) + + const iter = spans[1]! + const attr = iter.attributes!['tanstack.ai.system_prompt.metadata'] + expect(typeof attr).toBe('string') + expect(JSON.parse(attr as string)).toEqual([ + null, + { cache_control: { type: 'ephemeral' } }, + null, + ]) + }) + + it('does not attach systemPrompt metadata attribute when no entry carries metadata', async () => { + const { tracer, spans } = createFakeTracer() + const mw = otelMiddleware({ tracer, captureContent: true }) + const ctx = makeCtx() + + await runToIterationStart(mw, ctx, { + messages: [{ role: 'user', content: 'hi' }], + systemPrompts: ['plain-a', { content: 'plain-b' }], + }) + + const iter = spans[1]! + expect( + iter.attributes!['tanstack.ai.system_prompt.metadata'], + ).toBeUndefined() + }) + it('captureContent=false emits no message events', async () => { const { tracer, spans } = createFakeTracer() const mw = otelMiddleware({ tracer }) diff --git a/packages/typescript/ai/tests/system-prompts.test.ts b/packages/typescript/ai/tests/system-prompts.test.ts new file mode 100644 index 000000000..5d463c149 --- /dev/null +++ b/packages/typescript/ai/tests/system-prompts.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { normalizeSystemPrompts } from '../src/system-prompts' + +describe('normalizeSystemPrompts', () => { + it('returns an empty array when input is undefined', () => { + expect(normalizeSystemPrompts(undefined)).toEqual([]) + }) + + it('returns an empty array when input is empty', () => { + expect(normalizeSystemPrompts([])).toEqual([]) + }) + + it('wraps plain strings into `{ content }` objects', () => { + expect(normalizeSystemPrompts(['a', 'b'])).toEqual([ + { content: 'a' }, + { content: 'b' }, + ]) + }) + + it('passes object-form entries through unchanged', () => { + const meta = { cache_control: { type: 'ephemeral' } } + expect( + normalizeSystemPrompts([ + { content: 'cached', metadata: meta }, + { content: 'plain' }, + ]), + ).toEqual([{ content: 'cached', metadata: meta }, { content: 'plain' }]) + }) + + it('mixes plain strings and object-form in order', () => { + expect( + normalizeSystemPrompts(['first', { content: 'second' }, 'third']), + ).toEqual([ + { content: 'first' }, + { content: 'second' }, + { content: 'third' }, + ]) + }) + + it('throws TypeError naming the offending index when object-form content is not a string', () => { + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + normalizeSystemPrompts(['ok', { metadata: {} } as any]), + ).toThrow(/systemPrompts\[1\]: content must be a string, got undefined/) + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + normalizeSystemPrompts([{ content: 42 as any }]), + ).toThrow(/systemPrompts\[0\]: content must be a string, got number/) + }) + + it('throws TypeError when entry is neither string nor object', () => { + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + normalizeSystemPrompts(['ok', 123 as any]), + ).toThrow(/systemPrompts\[1\]: expected a string or .* got number/) + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + normalizeSystemPrompts([null as any]), + ).toThrow(/systemPrompts\[0\]: expected a string or .* got null/) + }) +}) diff --git a/packages/typescript/ai/tests/test-utils.ts b/packages/typescript/ai/tests/test-utils.ts index a2fbf4af0..dad3ff28f 100644 --- a/packages/typescript/ai/tests/test-utils.ts +++ b/packages/typescript/ai/tests/test-utils.ts @@ -172,6 +172,7 @@ export function createMockAdapter(options: { }, toolCapabilities: [] as ReadonlyArray, toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, }, chatStream: (opts: any) => { calls.push(opts) diff --git a/packages/typescript/ai/tests/type-check.test.ts b/packages/typescript/ai/tests/type-check.test.ts index 82d05e64d..f82f46d9b 100644 --- a/packages/typescript/ai/tests/type-check.test.ts +++ b/packages/typescript/ai/tests/type-check.test.ts @@ -37,6 +37,7 @@ const mockAdapter = { }, toolCapabilities: [] as ReadonlyArray, toolCallMetadata: undefined as unknown, + systemPromptMetadata: undefined as never, }, chatStream: async function* () {}, structuredOutput: async () => ({ data: {}, rawText: '{}' }), diff --git a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts index 63a2f5011..3fbc726fd 100644 --- a/packages/typescript/openai-base/src/adapters/chat-completions-text.ts +++ b/packages/typescript/openai-base/src/adapters/chat-completions-text.ts @@ -1,4 +1,4 @@ -import { EventType } from '@tanstack/ai' +import { EventType, normalizeSystemPrompts } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' @@ -1088,10 +1088,11 @@ export abstract class OpenAIBaseChatCompletionsTextAdapter< const messages: Array = [] // Add system prompts first - if (options.systemPrompts && options.systemPrompts.length > 0) { + const systemPrompts = normalizeSystemPrompts(options.systemPrompts) + if (systemPrompts.length > 0) { messages.push({ role: 'system', - content: options.systemPrompts.join('\n'), + content: systemPrompts.map((p) => p.content).join('\n'), }) } diff --git a/packages/typescript/openai-base/src/adapters/responses-text.ts b/packages/typescript/openai-base/src/adapters/responses-text.ts index 2c41aed23..60d7ee818 100644 --- a/packages/typescript/openai-base/src/adapters/responses-text.ts +++ b/packages/typescript/openai-base/src/adapters/responses-text.ts @@ -1,4 +1,4 @@ -import { EventType } from '@tanstack/ai' +import { EventType, normalizeSystemPrompts } from '@tanstack/ai' import { BaseTextAdapter } from '@tanstack/ai/adapters' import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' import { generateId, transformNullsToUndefined } from '@tanstack/ai-utils' @@ -1599,10 +1599,11 @@ export abstract class OpenAIBaseResponsesTextAdapter< }), ...(options.topP !== undefined && { top_p: options.topP }), ...(options.metadata !== undefined && { metadata: options.metadata }), - ...(options.systemPrompts && - options.systemPrompts.length > 0 && { - instructions: options.systemPrompts.join('\n'), - }), + ...(() => { + const prompts = normalizeSystemPrompts(options.systemPrompts) + if (prompts.length === 0) return {} + return { instructions: prompts.map((p) => p.content).join('\n') } + })(), input, // Conditional spread: `tools: undefined` would clobber any // modelOptions.tools the caller set above. diff --git a/testing/e2e/src/routes/api.chat.ts b/testing/e2e/src/routes/api.chat.ts index 2f9cb83ed..b74d493dd 100644 --- a/testing/e2e/src/routes/api.chat.ts +++ b/testing/e2e/src/routes/api.chat.ts @@ -56,6 +56,27 @@ export const Route = createFileRoute('/api/chat')({ try { const systemPrompt = config.systemPrompt ?? DEFAULT_SYSTEM_PROMPT + // Test-only flag — when truthy, the route promotes the system + // prompt to object-form and attaches Anthropic `cache_control` + // metadata. Enables system-prompt-metadata.spec.ts to verify + // cache_control reaches the wire via the aimock journal. + const systemPromptCacheControl = + fp.systemPromptCacheControl === true + ? ({ type: 'ephemeral' as const } as const) + : undefined + const systemPrompts = systemPromptCacheControl + ? [ + { + content: systemPrompt, + metadata: { cache_control: systemPromptCacheControl }, + // The route is provider-generic; the metadata type is + // adapter-narrowed and only meaningful for Anthropic, so + // a single bridge cast lives here at the test entry. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + ] + : [systemPrompt] + // Two structured-output-streaming features differ only in which // schema they bind to. Branched per-feature so TS can pick the // right `chat()` overload without a `never` cast. @@ -64,7 +85,7 @@ export const Route = createFileRoute('/api/chat')({ ? chat({ ...adapterOptions, modelOptions: config.modelOptions, - systemPrompts: [systemPrompt], + systemPrompts, messages: params.messages, threadId: params.threadId, runId: params.runId, @@ -76,7 +97,7 @@ export const Route = createFileRoute('/api/chat')({ ? chat({ ...adapterOptions, modelOptions: config.modelOptions, - systemPrompts: [systemPrompt], + systemPrompts, messages: params.messages, threadId: params.threadId, runId: params.runId, @@ -88,7 +109,7 @@ export const Route = createFileRoute('/api/chat')({ ...adapterOptions, tools: config.tools, modelOptions: config.modelOptions, - systemPrompts: [systemPrompt], + systemPrompts, agentLoopStrategy: maxIterations(5), messages: params.messages, threadId: params.threadId, diff --git a/testing/e2e/tests/system-prompt-metadata.spec.ts b/testing/e2e/tests/system-prompt-metadata.spec.ts new file mode 100644 index 000000000..bb578dcfa --- /dev/null +++ b/testing/e2e/tests/system-prompt-metadata.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from './fixtures' + +/** + * End-to-end coverage for `systemPrompts: [{ content, metadata: { cache_control } }]` + * on the Anthropic adapter. + * + * Wire-shape coverage lives in the unit test + * `packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts` — + * `it('attaches cache_control to system TextBlockParams via systemPrompts + * metadata')` directly inspects the body passed to the Anthropic SDK and + * asserts the structured `system: [{ type: 'text', text, cache_control }]` + * payload. Replicating that assertion here is impossible: aimock's + * journal normalises Anthropic requests into an OpenAI-shaped + * `ChatCompletionRequest` for storage and drops unknown fields like + * `cache_control` in the process. + * + * What this spec covers (which the unit test cannot): + * - `chatParamsFromRequestBody` accepts the request without rejecting + * `forwardedProps.systemPromptCacheControl`. + * - The object-form `systemPrompts` shape survives the JSON wire from + * test → route → adapter without throwing (in particular, + * `normalizeSystemPrompts`' runtime validation accepts the shape). + * - The Anthropic SDK accepts the request as built (a malformed + * `system` TextBlockParam would be rejected by the SDK or aimock). + * - The stream completes with `RUN_FINISHED`, proving the full + * middleware → adapter → SDK → server path is unaffected by the + * presence of `metadata.cache_control`. + */ +test.describe('Anthropic systemPrompts metadata — wire path', () => { + test('object-form systemPrompts with metadata.cache_control completes end-to-end on Anthropic', async ({ + request, + testId, + aimockPort, + }) => { + const body = { + threadId: 'thread-sysprompt-meta-1', + runId: 'run-sysprompt-meta-1', + state: {}, + messages: [ + { id: 'u1', role: 'user', content: '[chat] recommend a guitar' }, + ], + tools: [], + context: [], + forwardedProps: { + provider: 'anthropic', + feature: 'chat', + testId, + aimockPort, + // Opt-in flag handled by `api.chat.ts` — promotes the system + // prompt to object-form `{ content, metadata: { cache_control: + // { type: 'ephemeral' } } }`. Exercising this flag with the + // matching aimock fixture proves the full HTTP path tolerates the + // structured shape end-to-end. + systemPromptCacheControl: true, + }, + } + const response = await request.post('/api/chat', { + data: body, + headers: { 'Content-Type': 'application/json' }, + }) + expect( + response.ok(), + `expected 200, got ${response.status()}: ${await response.text()}`, + ).toBe(true) + const text = await response.text() + expect(text).toContain('RUN_FINISHED') + // No RUN_ERROR — the adapter accepted the structured system prompt + // and the SDK accepted the resulting `TextBlockParam` array. (See the + // unit test for the actual wire-shape assertion.) + expect(text).not.toContain('RUN_ERROR') + }) +})