Skip to content

Commit 496db9c

Browse files
feat(ai): systemPrompts accept { content, metadata } for per-provider metadata (#575)
* chore(monorepo): tighten build/CI scope, defensive changeset ignores, bump vite - Exclude testing/** alongside examples/** in build, build:all, test:ci, test:pr, test:lib, test:types, test:eslint, test:build, test:coverage. Internal test harnesses (testing/e2e, testing/panel) had build scripts but no exclude, so they were getting built on every release pipeline. - Add an `ignore` list to .changeset/config.json covering every in-workspace private package. Defends against accidental publication if `"private": true` is ever dropped from a package by mistake. - Bump vite from ^7.2.7 to ^7.3.3 across every declaring package.json. Resolves a transitive version skew (some dev-only plugins pull vite@7.3.x) that was breaking typechecking of vite.config.ts files. - Add `engines` to the root package.json: `node >=24`, `pnpm >=11.0.0`. * chore(tsconfig): consolidate to root tsconfig.base.json and include tests - Introduce tsconfig.base.json at the repo root with the shared compilerOptions. Root tsconfig.json now extends it for the small set of root-level files (scripts, config files) we still typecheck. - Migrate every package tsconfig to extend tsconfig.base.json with a consistent shape: only the package's unique compilerOptions overrides (outDir, rootDir where needed, JSX runtime, framework lib) plus a uniform `include: ["src", "tests"]` / `exclude: ["node_modules", "dist"]`. - Drop brittle one-off patterns: per-package duplicated compilerOptions (ai-devtools, ai-isolate-cloudflare), explicit single-test-file includes (ai-anthropic, ai-gemini), and `**/*.config.ts` excludes that were silently hiding test files from `tsc`. - Config files (vite.config.ts, vitest.config.ts) are no longer in the include set. They're typechecked at build time by vite/vitest themselves and the cross-tool type matrix makes them brittle to check via `tsc`. Net effect: `pnpm test:types` now actually typechecks the tests in every package. Prior to this commit several packages were silently excluding tests from the typecheck pipeline. * refactor(ai-gemini): merge GeminiThinking{,Advanced}Options into one interface The old shape kept thinking config in two separate interfaces — one with required `includeThoughts` + optional `thinkingBudget`, the other with `thinkingLevel` — and intersected both into ExternalTextProviderOptions. The intersection produced a `thinkingConfig` shape where neither `includeThoughts`+`thinkingBudget` nor `thinkingLevel` could be passed cleanly, even though the adapter reads both at runtime (see src/adapters/text.ts mapCommonOptionsToGemini). Merge into a single GeminiThinkingOptions with all three fields optional. Drop GeminiThinkingAdvancedOptions entirely. Update model-meta intersections and the sync-provider-models template that references the type. GeminiThinkingOptions is the only thinking type exported from @tanstack/ai-gemini, so no public-API breakage. GeminiThinkingAdvancedOptions was never exported. * refactor(ai-anthropic): drop modelOptions.system escape hatch `system` was sneaking into modelOptions via `validKeys` and getting spread over the system block constructed from systemPrompts. That was a leaky abstraction: it bypassed the cross-provider `systemPrompts` API and forced users to construct Anthropic's TextBlockParam shape manually to attach features like cache_control. - Remove `'system'` from validKeys in adapters/text.ts. The adapter no longer accepts a system override from modelOptions. - Keep `system?: string | Array<TextBlockParam>` on InternalTextProviderOptions (the adapter still constructs it internally from `options.systemPrompts`). Mark it explicitly as internal in the JSDoc. - Delete the test that exercised the override behaviour and prune the `system` field + `& { system: string }` satisfies intersection from the one other test that was using modelOptions.system. Regression to be aware of: there is currently no public way to attach Anthropic `cache_control` to system prompts. A follow-up will extend `systemPrompts` to accept `{ content, metadata }` with provider-specific metadata (cache_control for Anthropic, others later). * fix(ai): expose Logger via the adapter-internals subpath `Logger` was declared as an `export interface` in src/logger/types.ts but wasn't re-exported from src/adapter-internals.ts. Provider adapter packages consume internals only via the `@tanstack/ai/adapter-internals` subpath, so without this re-export they couldn't reach the `Logger` type without an ambient import that would break the encapsulation boundary. Surfaced by the openai-base tests once `tsc` started checking tests. * test: fix type rot and unsafe casts surfaced by typecheck standardization Including tests in `tsc` (see the tsconfig commit) surfaced ~200 pre-existing type errors across the test suite that had silently rotted as source types evolved. This commit fixes them and tightens the remaining type assertions. Patterns fixed: - Stale model name literals (`claude-3-7-sonnet-20250219` → `claude-3-7-sonnet`, `grok-4-0709` → `grok-4`, `chatgpt-4.0` → `chatgpt-4o-latest`, etc.). - EventType string literals replaced with the enum values from `@tanstack/ai`/`@ag-ui/core` in groq/grok/openai-base/openai tests. - Removed/renamed provider option fields (`generationConfig.*` flattened onto top-level Gemini options; `system` removed from anthropic modelOptions tests). - Required fields added to mock generation result objects (`id` on image/speech/transcription mocks, `id` + `usage` on summarize mocks) to satisfy GenerationFetcher result types. - `MakeInputModalitiesTypes<['text', 'image', ...]>` wrappers and provider-typed content parts (`AnthropicTextPart`, `OpenAITextPart`, `GeminiImagePart`, etc.) plumbed into the model-meta tests. - `tools` array entries replaced with typed `AnyClientTool` factories for the elevenlabs realtime tests. - Cloudflare worker tests: 7 `as any` casts collapsed into a single `readJson<T>` helper with one trust-boundary cast and `Extract<>` types per response status arm. - ai-react/ai-solid `useChat({ onToolCall })` tests: `onToolCall` is not a public UseChatOptions field — it's set internally by ChatClient via the `tools` array. Deleted one test of nonexistent behaviour and removed dead `onToolCall` options from two `addToolResult` tests. - ai-react/ai-solid `useGeneration` tests: explicit generic arguments instead of `onResult ... as any`; `as const` on inline chunk arrays so StreamChunk literals infer with EventType enum values intact. - ai-svelte `create-generation` tests: EventType enum + required fields on AGUIEvent shapes (`threadId` on RUN_STARTED/RUN_FINISHED, `message` on RUN_ERROR). - openai-base tests now build a real `new OpenAI({ apiKey })` and monkey-patch a typed `MockChatCompletionCreate` / `MockResponsesCreate` signature instead of `as unknown as OpenAI`. Remaining `as`-style casts in the touched files: 5 total, all at trust boundaries with justification comments (Response.json() → typed shape; discriminated-union narrowing for the schemaless useChat branch; AGUI RUN_ERROR carrying both spec-shape `message` and legacy `error.message`). `@ts-expect-error` is used in one place (audio-adapter.test.ts) for the "unsupported model name is rejected" test, replacing an `as never` cast. No production code changes other than: - A new export of an existing `Logger` interface (separate commit). - The anthropic modelOptions.system removal (separate commit). * docs(contributing): add CONTRIBUTING.md, editorconfig, vscode recommendations - CONTRIBUTING.md covering prereqs (Node 24, pnpm 11), initial setup, repo layout, day-to-day commands, the per-package tsconfig pattern, where to add unit tests, the (mandatory) E2E coverage matrix, changesets, PR flow, and how to add a new provider adapter. Documents the known gaps: .vue/.svelte SFCs are not linted today, and build configs are not in the `tsc` pass. - .editorconfig with the repo's existing conventions (LF, utf-8, 2-space, final newline). - .vscode/extensions.json recommending eslint, prettier, vitest explorer, nx, svelte, vue, editorconfig. The PR template already linked to a missing CONTRIBUTING.md; this fixes that broken link. * ci: apply automated fixes * chore(changesets): use ts-* glob instead of listing each example explicitly * docs: drop incorrect Node.js v24+ requirement from READMEs and CONTRIBUTING.md * fix: scope Node version constraint to ai-isolate-node (Node >= 22 for isolated-vm) The previous "Node 24+" claim was overreaching: - Only `@tanstack/ai-isolate-node` (via `isolated-vm`) has a Node version floor. Everything else in the workspace runs fine on older Node. - `isolated-vm`'s actual `engines.node` is `>=22.0.0` (see https://github.com/laverdet/isolated-vm/blob/main/package.json), not 24. Changes: - Root package.json: drop the `node: ">=24"` entry from engines (keep the pnpm constraint). No global Node floor for development. - packages/typescript/ai-isolate-node/package.json: bump `engines.node` from `>=18` to `>=22` to match upstream. - packages/typescript/ai-isolate-node/README.md: same — say `>=22`, and add the `--no-node-snapshot` flag note for Node 20+ runtime usage. * chore: address CodeRabbit feedback on PR #572 - package.json: relax engines.pnpm from `>=11.0.0` to `>=10.17.0` to match CONTRIBUTING.md ("pnpm 10.17.0 or newer"). packageManager still pins pnpm@11.1.1 for corepack users. - packages/typescript/ai-isolate-node/README.md: rewrite the `--no-node-snapshot` bullet so it no longer says "Node.js 20.x and later" alongside the "Node.js >= 22" minimum stated two bullets above. The flag is required by isolated-vm on every Node version we support. - packages/typescript/ai-gemini/tests/model-meta.test.ts: the "thinking models should allow thinkingConfig" type-test was asserting `stopSequences` (a placeholder swapped in during the typecheck rot cleanup). Restore the original intent: assert that `thinkingConfig` is present on `GeminiChatModelProviderOptionsByName['gemini-2.5-pro']`. - packages/typescript/ai-openai/tests/openai-adapter.test.ts: the test passes `systemPrompts: ['Stay concise']` but never checked that it arrived on the outbound payload. Add the missing `instructions: 'Stay concise'` assertion (the Responses API field the adapter writes `systemPrompts.join('\n')` into). Skipped: - packages/typescript/ai-vue-ui/tsconfig.json (and sibling -ui packages): CodeRabbit suggested adding `"tests"` to `include`. The *-ui packages have no test files on disk and use `composite: true` with `rootDir: src`, so widening include would point at a non-existent directory. * feat(ai): systemPrompts accept { content, metadata } with adapter-inferred metadata typing Extends `chat({ systemPrompts })` to accept either a plain string (existing shape — backward compatible) or `{ content, metadata }`. The structured form's `metadata` type is inferred from the adapter at the chat() call site via a new `TSystemPromptMetadata` generic on `TextAdapter` — no `satisfies` needed by callers. - Anthropic declares `AnthropicSystemPromptMetadata` → users get `cache_control` autocomplete + type-checking. - Adapters with no per-prompt metadata (OpenAI, Gemini, Ollama, OpenRouter, openai-base) inherit the default `never`, which makes the `metadata` field unusable at the call site. Passing metadata to those adapters is a TypeScript error. Closes the regression introduced by removing the `modelOptions.system` escape hatch in the audit PR — there is now a public, typed path for attaching Anthropic `cache_control` to system prompts. Plumbing - New `TSystemPromptMetadata = never` generic on `TextAdapter` / `BaseTextAdapter`, surfaced via `'~types'['systemPromptMetadata']`. `TextActivityOptions.systemPrompts` is now `Array<SystemPrompt<TAdapter['~types']['systemPromptMetadata']>>`. - `AnyTextAdapter` extended to 7 generic slots. - `TextOptions.systemPrompts` (the wide internal shape adapters receive) is `Array<SystemPrompt>`; adapters call `normalizeSystemPrompts<...>()` to narrow. - Chat engine + middleware context/config widened to carry `Array<SystemPrompt>` so metadata flows through to the adapter. - OpenTelemetry middleware extracts `.content` for span events; per-prompt metadata is dropped from spans. - `@tanstack/ai-event-client` mirrors the `SystemPrompt` shape locally (avoids a circular import with `@tanstack/ai`) and projects metadata away on the devtools wire. Adapter mappings - Anthropic reads `metadata.cache_control` and attaches it to the matching `TextBlockParam`. - OpenAI / Gemini / Ollama / OpenRouter / openai-base call `normalizeSystemPrompts()` and join `.content` for their respective `instructions` / `system` / `systemInstruction` fields. (Their metadata type is `never`, so the field can't be set anyway.) Tests - ai-anthropic: new test verifies `cache_control` flows from `systemPrompts[i].metadata` onto the outbound `TextBlockParam`, and plain-string entries still produce metadata-less blocks. - ai-openai: new test verifies mixed string + object-form input (without metadata) produces the expected joined `instructions`. * ci: apply automated fixes * address PR #575 review feedback Maps to tombeckenham's review items C1, I1–I6: - C1 (runtime validation): `normalizeSystemPrompts` throws TypeError naming the offending index when an object-form entry's content isn't a string, so stale call sites can't stream a literal "undefined" into the model. New test file `packages/typescript/ai/tests/system-prompts.test.ts` covers the happy paths and the throw cases. - I1 (E2E coverage): `testing/e2e/tests/system-prompt-metadata.spec.ts` drives the full Anthropic HTTP path with object-form systemPrompts + metadata.cache_control. Wire-shape assertion stays in the ai-anthropic unit test (aimock's journal normalises Anthropic into an OpenAI-shaped request and drops `cache_control`, so a journal-based assertion isn't reliable — the spec asserts end-to-end success instead). Required a test-only `systemPromptCacheControl` opt-in on `api.chat.ts` to flip the system prompt to object form. - I2 (middleware widened-shape coverage): new `middleware.test.ts > should preserve object-form systemPrompts through middleware` asserts the middleware sees `Array<SystemPrompt>`, not a pre-flattened `Array<string>`, and that mutations preserve metadata through to the adapter. - I3 (per-adapter mapping coverage): direct unit tests for Gemini (`systemInstruction` joined; foreign metadata dropped), Ollama (`messages.unshift({role:'system'})` joined; foreign metadata dropped), and OpenRouter (positional `{role:'system'}` message; foreign metadata dropped). - I4 (OTel metadata observability): when `captureContent: true` and at least one entry carries metadata, the iteration span gains a `tanstack.ai.system_prompt.metadata` JSON attribute (positional per-prompt array, `null` for plain strings / no metadata). Kept off span events so the existing one-event-per-message GenAI semconv stays intact. New tests in `tests/middlewares/otel.test.ts` cover both the set and the absent path. - I5 (doc/changeset wording): replaced "silently ignore" with the accurate "carries no meaningful value … silently dropped, never written to the wire" framing in `SystemPrompt`'s JSDoc, the `TextOptions.systemPrompts` JSDoc, and the changeset. Removed the misleading `satisfies AnthropicSystemPromptMetadata` from the `@example` (the adapter narrows the metadata field at the call site, so a `satisfies` cast is contradicted by the tests). - I6 (devtools structural guard): `ai-event-client/tests/devtools-middleware-shape.test.ts` re-declares the local `DevtoolsSystemPrompt` mirror and uses `expectTypeOf` to assert mutual assignability with `@tanstack/ai`'s `SystemPrompt`. If the canonical shape gains a third variant the guard breaks at type-check time, forcing the maintainer to update the mirror in `devtools-middleware.ts`. Suggestions S1–S5 deferred per the reviewer's "follow-up acceptable" call. * ci: apply automated fixes * fix CI: ai-event-client cross-package import + system-prompts lint Two CI failures on the previous review-feedback commit, both follow-ups to the same patch: - `@tanstack/ai-event-client:test:types` failed because the new `devtools-middleware-shape.test.ts` imported `SystemPrompt` from `@tanstack/ai`, but `@tanstack/ai-event-client` only declares `@tanstack/ai` as a peerDependency. Nx's project graph therefore doesn't see a build edge and the `^build` predecessor for test:types never produces the dist files tsc needs. Adding a `workspace:*` devDep would close the loop but reintroduce the circular dev/runtime dep the current architecture is built to avoid. Move the structural guard test into `@tanstack/ai/tests/` instead. `@tanstack/ai` has no edge to ai-event-client's source for this assertion (the mirror gets re-declared inline), and the test now lives alongside the type it's guarding. - `@tanstack/ai:test:eslint` failed on `@typescript-eslint/no-unnecessary-condition` at the C1 runtime checks inside `normalizeSystemPrompts`. TS narrows `p` to the object arm after the string branch, so the `p === null || typeof p !== 'object'` guard looks redundant to the rule even though it's deliberate defence-in-depth at a public API boundary. Cast through `unknown` to drop the narrow and re-validate without disabling the rule. Same treatment for the `p.content` typeof check — read `content` off an `unknown`-typed view of the candidate so the runtime check is visible to the linter without an eslint-disable. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent ed53773 commit 496db9c

33 files changed

Lines changed: 928 additions & 53 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
---
2+
'@tanstack/ai': minor
3+
'@tanstack/ai-anthropic': minor
4+
'@tanstack/ai-event-client': patch
5+
'@tanstack/ai-gemini': patch
6+
'@tanstack/ai-ollama': patch
7+
'@tanstack/ai-openai': patch
8+
'@tanstack/ai-openrouter': patch
9+
'@tanstack/openai-base': patch
10+
---
11+
12+
feat(ai): `systemPrompts` accept `{ content, metadata }` with adapter-inferred metadata typing
13+
14+
`chat({ systemPrompts })` now accepts either a plain string (the existing
15+
shape — fully backward compatible) or `{ content, metadata }`. The `metadata`
16+
field's type is inferred from the adapter via a new
17+
`TSystemPromptMetadata` generic on `TextAdapter` / `BaseTextAdapter`:
18+
19+
- `@tanstack/ai-anthropic` declares `AnthropicSystemPromptMetadata`
20+
users get `cache_control` autocomplete and type-checking on
21+
`systemPrompts[i].metadata` for Anthropic chats.
22+
- Adapters with no per-prompt metadata (OpenAI, Gemini, Ollama,
23+
OpenRouter, openai-base) inherit the default `never`, which means the
24+
`metadata` field carries no meaningful value at the call site —
25+
TypeScript only accepts `undefined` there. Provider-foreign metadata
26+
that reaches an adapter via JS / `as any` is silently dropped, never
27+
written to the wire.
28+
29+
```ts
30+
import { chat } from '@tanstack/ai'
31+
import { anthropicText } from '@tanstack/ai-anthropic'
32+
33+
// Anthropic — `cache_control` is autocompleted, no `satisfies` needed.
34+
chat({
35+
adapter: anthropicText({ apiKey }, 'claude-sonnet-4-6'),
36+
systemPrompts: [
37+
{
38+
content: 'Stable instructions — cache me.',
39+
metadata: { cache_control: { type: 'ephemeral' } },
40+
},
41+
'Volatile per-request instruction.',
42+
],
43+
})
44+
45+
// OpenAI — `metadata` is `never`; only `undefined` is assignable, so the
46+
// field is effectively unusable. The object form without `metadata` still
47+
// works for portability.
48+
chat({
49+
adapter: openaiText({ apiKey }, 'gpt-4o-mini'),
50+
systemPrompts: [
51+
'Plain string.',
52+
{ content: 'Object form without metadata is allowed.' },
53+
],
54+
})
55+
```
56+
57+
New exports:
58+
59+
- `@tanstack/ai`: `SystemPrompt`, `NormalizedSystemPrompt` types and the
60+
`normalizeSystemPrompts()` helper adapters use to normalize the wide
61+
input shape to `{ content, metadata? }` before consumption.
62+
- `@tanstack/ai-anthropic`: `AnthropicSystemPromptMetadata` interface
63+
(currently exposes `cache_control` for prompt caching).
64+
65+
Internal:
66+
67+
- New `TSystemPromptMetadata = never` generic on `TextAdapter` /
68+
`BaseTextAdapter`, surfaced via `'~types'['systemPromptMetadata']`
69+
for inference at the `chat()` call site.
70+
- Anthropic adapter reads `metadata.cache_control` and attaches it to
71+
the corresponding `TextBlockParam`.
72+
- All other text adapters call `normalizeSystemPrompts()` and join
73+
`.content` for their respective `instructions` / `system` /
74+
`systemInstruction` fields. Foreign metadata that reaches them via JS
75+
/ `as any` is dropped (never written to the wire).
76+
- `normalizeSystemPrompts()` is the public API boundary and throws
77+
`TypeError` (naming the offending index) for object-form entries whose
78+
`content` isn't a string — preventing literal `"undefined"` from
79+
reaching the model on stale call sites.
80+
- OpenTelemetry middleware attaches per-prompt metadata as the
81+
`tanstack.ai.system_prompt.metadata` JSON span attribute when
82+
`captureContent: true` and at least one entry carries metadata, so
83+
observability backends can distinguish cache hit/miss for Anthropic.
84+
- `@tanstack/ai-event-client` mirrors the `SystemPrompt` shape locally
85+
(avoids a circular import) and projects metadata away on the devtools
86+
wire — devtools UI still receives `Array<string>`.

packages/typescript/ai-anthropic/src/adapters/text.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { EventType } from '@tanstack/ai'
1+
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
22
import { BaseTextAdapter } from '@tanstack/ai/adapters'
33
import { convertToolsToProviderFormat } from '../tools/tool-converter'
44
import { validateTextProviderOptions } from '../text/text-provider-options'
@@ -38,6 +38,7 @@ import type {
3838
TextOptions,
3939
} from '@tanstack/ai'
4040
import type {
41+
AnthropicSystemPromptMetadata,
4142
ExternalTextProviderOptions,
4243
InternalTextProviderOptions,
4344
} from '../text/text-provider-options'
@@ -115,7 +116,12 @@ export class AnthropicTextAdapter<
115116
TProviderOptions,
116117
TInputModalities,
117118
AnthropicMessageMetadataByModality,
118-
TToolCapabilities
119+
TToolCapabilities,
120+
// TToolCallMetadata — anthropic has no tool-call metadata round-tripping
121+
unknown,
122+
// TSystemPromptMetadata — narrows `systemPrompts[i].metadata` at the
123+
// chat() call site so users get `cache_control` autocomplete.
124+
AnthropicSystemPromptMetadata
119125
> {
120126
readonly kind = 'text' as const
121127
readonly name = 'anthropic' as const
@@ -356,11 +362,22 @@ export class AnthropicTextAdapter<
356362
temperature: options.temperature,
357363
top_p: options.topP,
358364
messages: formattedMessages,
359-
system: options.systemPrompts?.length
360-
? options.systemPrompts.map(
361-
(text): TextBlockParam => ({ type: 'text', text }),
365+
system: (() => {
366+
const normalized =
367+
normalizeSystemPrompts<AnthropicSystemPromptMetadata>(
368+
options.systemPrompts,
362369
)
363-
: undefined,
370+
if (normalized.length === 0) return undefined
371+
return normalized.map(
372+
(p): TextBlockParam => ({
373+
type: 'text',
374+
text: p.content,
375+
...(p.metadata?.cache_control && {
376+
cache_control: p.metadata.cache_control,
377+
}),
378+
}),
379+
)
380+
})(),
364381
tools: tools,
365382
...validProviderOptions,
366383
}

packages/typescript/ai-anthropic/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
type AnthropicTextConfig,
1111
type AnthropicTextProviderOptions,
1212
} from './adapters/text'
13+
export type { AnthropicSystemPromptMetadata } from './text/text-provider-options'
1314

1415
// Summarize - thin factory functions over @tanstack/ai's ChatStreamSummarizeAdapter
1516
export {

packages/typescript/ai-anthropic/src/text/text-provider-options.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,43 @@ import type {
44
BetaToolChoiceAuto,
55
BetaToolChoiceTool,
66
} from '@anthropic-ai/sdk/resources/beta/messages/messages'
7+
import type { CacheControlEphemeral } from '@anthropic-ai/sdk/resources'
78
import type { AnthropicTool } from '../tools'
89
import type {
910
MessageParam,
1011
TextBlockParam,
1112
} from '@anthropic-ai/sdk/resources/messages'
1213

14+
/**
15+
* Per-prompt metadata Anthropic understands on `systemPrompts` entries.
16+
*
17+
* Used via the structured form of `systemPrompts`:
18+
*
19+
* @example
20+
* import type { AnthropicSystemPromptMetadata } from '@tanstack/ai-anthropic'
21+
*
22+
* chat({
23+
* adapter: anthropicText(),
24+
* model: 'claude-sonnet-4-6',
25+
* systemPrompts: [
26+
* {
27+
* content: 'Stable instructions — cache me.',
28+
* metadata: { cache_control: { type: 'ephemeral' } } satisfies AnthropicSystemPromptMetadata,
29+
* },
30+
* 'Volatile per-request instruction.',
31+
* ],
32+
* })
33+
*/
34+
export interface AnthropicSystemPromptMetadata {
35+
/**
36+
* Anthropic prompt-caching control applied to this system prompt's
37+
* `TextBlockParam`.
38+
*
39+
* @see https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
40+
*/
41+
cache_control?: CacheControlEphemeral
42+
}
43+
1344
export interface AnthropicContainerOptions {
1445
/**
1546
* Container identifier for reuse across requests.

packages/typescript/ai-anthropic/tests/anthropic-adapter.test.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,64 @@ describe('Anthropic adapter option mapping', () => {
117117
])
118118
})
119119

120+
it('attaches cache_control to system TextBlockParams via systemPrompts metadata', async () => {
121+
const mockStream = (async function* () {
122+
yield {
123+
type: 'content_block_start',
124+
index: 0,
125+
content_block: { type: 'text', text: '' },
126+
}
127+
yield {
128+
type: 'content_block_delta',
129+
index: 0,
130+
delta: { type: 'text_delta', text: 'ok' },
131+
}
132+
yield {
133+
type: 'message_delta',
134+
delta: { stop_reason: 'end_turn' },
135+
usage: { output_tokens: 1 },
136+
}
137+
yield { type: 'message_stop' }
138+
})()
139+
140+
mocks.betaMessagesCreate.mockResolvedValueOnce(mockStream)
141+
142+
const adapter = createAdapter('claude-3-7-sonnet')
143+
144+
for await (const _ of chat({
145+
adapter,
146+
messages: [{ role: 'user', content: 'Hi' }],
147+
systemPrompts: [
148+
{
149+
content: 'Stable instructions — cache me.',
150+
// metadata is narrowed to AnthropicSystemPromptMetadata via the
151+
// adapter's `~types['systemPromptMetadata']` declaration — no
152+
// `satisfies` needed.
153+
metadata: { cache_control: { type: 'ephemeral', ttl: '5m' } },
154+
},
155+
'Volatile per-request instruction.',
156+
],
157+
})) {
158+
// consume stream
159+
}
160+
161+
const [payload] = mocks.betaMessagesCreate.mock.calls[0]!
162+
163+
// Object-form prompts attach their metadata cache_control; plain strings
164+
// produce a TextBlockParam with no cache_control.
165+
expect(payload.system).toEqual([
166+
{
167+
type: 'text',
168+
text: 'Stable instructions — cache me.',
169+
cache_control: { type: 'ephemeral', ttl: '5m' },
170+
},
171+
{
172+
type: 'text',
173+
text: 'Volatile per-request instruction.',
174+
},
175+
])
176+
})
177+
120178
it('drops unknown modelOptions keys (e.g. `system`) and warns via logger.error', async () => {
121179
const mockStream = (async function* () {
122180
yield {
@@ -127,12 +185,12 @@ describe('Anthropic adapter option mapping', () => {
127185
yield {
128186
type: 'content_block_delta',
129187
index: 0,
130-
delta: { type: 'text_delta', text: 'Hello' },
188+
delta: { type: 'text_delta', text: 'ok' },
131189
}
132190
yield {
133191
type: 'message_delta',
134192
delta: { stop_reason: 'end_turn' },
135-
usage: { output_tokens: 3 },
193+
usage: { output_tokens: 1 },
136194
}
137195
yield { type: 'message_stop' }
138196
})()
@@ -148,8 +206,7 @@ describe('Anthropic adapter option mapping', () => {
148206
error: vi.fn(),
149207
}
150208

151-
const chunks: StreamChunk[] = []
152-
for await (const chunk of chat({
209+
for await (const _ of chat({
153210
adapter,
154211
messages: [{ role: 'user', content: 'Hi' }],
155212
systemPrompts: ['real system prompt'],
@@ -159,7 +216,7 @@ describe('Anthropic adapter option mapping', () => {
159216
} as unknown as AnthropicTextProviderOptions,
160217
debug: { logger, errors: true },
161218
})) {
162-
chunks.push(chunk)
219+
// consume stream
163220
}
164221

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

packages/typescript/ai-event-client/src/devtools-middleware.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@ interface DevtoolsModelMessage {
1212
toolCalls?: unknown
1313
}
1414

15+
/**
16+
* Mirrors `SystemPrompt` from `@tanstack/ai` structurally so this package
17+
* doesn't import from `@tanstack/ai` (which would introduce a circular dep,
18+
* see file-top comment).
19+
*/
20+
type DevtoolsSystemPrompt = string | { content: string; metadata?: unknown }
21+
1522
interface DevtoolsMiddlewareContext {
1623
requestId: string
1724
streamId: string
1825
conversationId?: string
1926
provider: string
2027
model: string
2128
source: 'client' | 'server'
22-
systemPrompts: Array<string>
29+
systemPrompts: ReadonlyArray<DevtoolsSystemPrompt>
2330
toolNames?: Array<string>
2431
options?: Record<string, unknown>
2532
modelOptions?: Record<string, unknown>
@@ -104,7 +111,13 @@ function buildEventContext(ctx: DevtoolsMiddlewareContext) {
104111
model: ctx.model,
105112
clientId: ctx.conversationId,
106113
source: ctx.source,
107-
systemPrompts: ctx.systemPrompts.length > 0 ? ctx.systemPrompts : undefined,
114+
// Devtools wire payload is plain strings; per-prompt metadata is
115+
// irrelevant for observation and would require devtools-UI changes to
116+
// render. Project metadata away here so the wire shape is unchanged.
117+
systemPrompts:
118+
ctx.systemPrompts.length > 0
119+
? ctx.systemPrompts.map((p) => (typeof p === 'string' ? p : p.content))
120+
: undefined,
108121
toolNames: ctx.toolNames,
109122
options: ctx.options,
110123
modelOptions: ctx.modelOptions,

packages/typescript/ai-gemini/src/adapters/text.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FinishReason } from '@google/genai'
2-
import { EventType } from '@tanstack/ai'
2+
import { EventType, normalizeSystemPrompts } from '@tanstack/ai'
33
import { BaseTextAdapter } from '@tanstack/ai/adapters'
44
import { convertToolsToProviderFormat } from '../tools/tool-converter'
55
import {
@@ -838,7 +838,12 @@ export class GeminiTextAdapter<
838838
: undefined,
839839
}
840840
: undefined,
841-
systemInstruction: options.systemPrompts?.join('\n'),
841+
systemInstruction: (() => {
842+
const prompts = normalizeSystemPrompts(options.systemPrompts)
843+
return prompts.length > 0
844+
? prompts.map((p) => p.content).join('\n')
845+
: undefined
846+
})(),
842847
tools: convertToolsToProviderFormat(options.tools),
843848
},
844849
}

0 commit comments

Comments
 (0)