From ae3ba886c66dd88e59b68e338a559b9ec54b8b55 Mon Sep 17 00:00:00 2001 From: Jan Kubica Date: Thu, 30 Jul 2026 10:20:22 +0200 Subject: [PATCH 1/2] fix(ai-anthropic): serialize document metadata --- .changeset/tidy-pdfs.md | 5 ++ packages/ai-anthropic/src/adapters/text.ts | 12 ++++- packages/ai-anthropic/src/message-types.ts | 8 +++ .../tests/anthropic-adapter.test.ts | 52 +++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 .changeset/tidy-pdfs.md diff --git a/.changeset/tidy-pdfs.md b/.changeset/tidy-pdfs.md new file mode 100644 index 0000000000..9816c81511 --- /dev/null +++ b/.changeset/tidy-pdfs.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-anthropic': patch +--- + +Fix document metadata serialization. diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index b9511d7069..2df903a49e 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -665,6 +665,7 @@ export class AnthropicTextAdapter< } case 'document': { const metadata = part.metadata as AnthropicDocumentMetadata | undefined + const title = metadata?.title ?? metadata?.filename const docSource: Base64PDFSource | URLPDFSource = part.source.type === 'data' ? { @@ -679,7 +680,16 @@ export class AnthropicTextAdapter< return { type: 'document', source: docSource, - ...metadata, + ...(metadata?.cache_control !== undefined && { + cache_control: metadata.cache_control, + }), + ...(metadata?.citations !== undefined && { + citations: metadata.citations, + }), + ...(metadata?.context !== undefined && { + context: metadata.context, + }), + ...(title !== undefined && { title }), } } case 'audio': diff --git a/packages/ai-anthropic/src/message-types.ts b/packages/ai-anthropic/src/message-types.ts index 63f13889ea..4d4602067c 100644 --- a/packages/ai-anthropic/src/message-types.ts +++ b/packages/ai-anthropic/src/message-types.ts @@ -8,6 +8,7 @@ import type { CacheControlEphemeral, + CitationsConfigParam, TextCitationParam, } from '@anthropic-ai/sdk/resources' @@ -68,8 +69,15 @@ export interface AnthropicDocumentMetadata { */ cache_control?: CacheControlEphemeral + citations?: CitationsConfigParam + context?: string + /** + * The document filename. Used as the document title when `title` is absent. + */ + filename?: string + title?: string } diff --git a/packages/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/ai-anthropic/tests/anthropic-adapter.test.ts index a1fd4319f0..ff22946087 100644 --- a/packages/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/ai-anthropic/tests/anthropic-adapter.test.ts @@ -8,6 +8,7 @@ import { } from '@tanstack/ai' import { AnthropicTextAdapter } from '../src/adapters/text' import type { AnthropicTextProviderOptions } from '../src/adapters/text' +import type { AnthropicDocumentMetadata } from '../src/message-types' import { ANTHROPIC_MAX_NONSTREAMING_TOKENS } from '../src/model-meta' import { z } from 'zod' @@ -82,6 +83,57 @@ describe('Anthropic adapter option mapping', () => { vi.clearAllMocks() }) + it('serializes only supported document metadata', async () => { + mocks.betaMessagesCreate.mockResolvedValueOnce(createTextStream('ok')) + + const adapter = createAdapter('claude-opus-4-1') + const metadata = { + mediaType: 'application/pdf', + cache_control: { type: 'ephemeral' }, + citations: { enabled: true }, + context: 'Quarterly report', + filename: 'report.pdf', + placeholder: 'Attachment: report.pdf', + } as AnthropicDocumentMetadata & { placeholder: string } + + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'data', + value: 'JVBERi0xLjQ=', + mimeType: 'application/pdf', + }, + metadata, + }, + ], + }, + ], + })) { + // consume stream + } + + const [payload] = mocks.betaMessagesCreate.mock.calls[0]! + + expect(payload.messages[0].content[0]).toEqual({ + type: 'document', + source: { + type: 'base64', + data: 'JVBERi0xLjQ=', + media_type: 'application/pdf', + }, + cache_control: { type: 'ephemeral' }, + citations: { enabled: true }, + context: 'Quarterly report', + title: 'report.pdf', + }) + }) + it('passes systemPrompts as TextBlockParam[] for prompt caching support', async () => { const mockStream = (async function* () { yield { From a2418d9d5cf3e9d577758703943affe6ba788d32 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:50:13 +1000 Subject: [PATCH 2/2] fix(ai-anthropic): apply metadata allowlist to image blocks, deprecate ignored mediaType --- .changeset/tidy-pdfs.md | 2 +- packages/ai-anthropic/src/adapters/text.ts | 4 +- packages/ai-anthropic/src/message-types.ts | 10 +--- .../tests/anthropic-adapter.test.ts | 46 +++++++++++++++++++ 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.changeset/tidy-pdfs.md b/.changeset/tidy-pdfs.md index 9816c81511..4f417471a4 100644 --- a/.changeset/tidy-pdfs.md +++ b/.changeset/tidy-pdfs.md @@ -2,4 +2,4 @@ '@tanstack/ai-anthropic': patch --- -Fix document metadata serialization. +Serialize only Anthropic-supported fields from document and image metadata; use `filename` as a fallback document title. diff --git a/packages/ai-anthropic/src/adapters/text.ts b/packages/ai-anthropic/src/adapters/text.ts index 2df903a49e..ec3ba259e1 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -660,7 +660,9 @@ export class AnthropicTextAdapter< return { type: 'image', source: imageSource, - ...metadata, + ...(metadata?.cache_control !== undefined && { + cache_control: metadata.cache_control, + }), } } case 'document': { diff --git a/packages/ai-anthropic/src/message-types.ts b/packages/ai-anthropic/src/message-types.ts index 4d4602067c..fec7615383 100644 --- a/packages/ai-anthropic/src/message-types.ts +++ b/packages/ai-anthropic/src/message-types.ts @@ -26,10 +26,7 @@ export type AnthropicImageMediaType = */ export interface AnthropicImageMetadata { /** - * The MIME type of the image. - * Required when using base64 source type. - * - * @see https://docs.anthropic.com/claude/docs/vision#supported-image-types + * @deprecated Ignored. The media type is taken from `source.mimeType`. */ mediaType?: AnthropicImageMediaType /** @@ -58,10 +55,7 @@ export type AnthropicDocumentMediaType = 'application/pdf' */ export interface AnthropicDocumentMetadata { /** - * The MIME type of the document. - * Required for document content, typically 'application/pdf'. - * - * @see https://docs.anthropic.com/claude/docs/pdf-support + * @deprecated Ignored. The media type is taken from `source.mimeType`. */ mediaType?: AnthropicDocumentMediaType /** diff --git a/packages/ai-anthropic/tests/anthropic-adapter.test.ts b/packages/ai-anthropic/tests/anthropic-adapter.test.ts index ff22946087..75be66c072 100644 --- a/packages/ai-anthropic/tests/anthropic-adapter.test.ts +++ b/packages/ai-anthropic/tests/anthropic-adapter.test.ts @@ -134,6 +134,52 @@ describe('Anthropic adapter option mapping', () => { }) }) + it('prefers document title over filename and serializes only cache_control for images', async () => { + mocks.betaMessagesCreate.mockResolvedValueOnce(createTextStream('ok')) + + const adapter = createAdapter('claude-opus-4-1') + + for await (const _ of chat({ + adapter, + messages: [ + { + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'data', + value: 'JVBERi0xLjQ=', + mimeType: 'application/pdf', + }, + metadata: { title: 'Q3 Report', filename: 'report.pdf' }, + }, + { + type: 'image', + source: { type: 'url', value: 'https://example.com/a.png' }, + metadata: { + mediaType: 'image/png', + cache_control: { type: 'ephemeral' }, + filename: 'a.png', + }, + }, + ], + }, + ], + })) { + // consume stream + } + + const [payload] = mocks.betaMessagesCreate.mock.calls[0]! + + expect(payload.messages[0].content[0].title).toBe('Q3 Report') + expect(payload.messages[0].content[1]).toEqual({ + type: 'image', + source: { type: 'url', url: 'https://example.com/a.png' }, + cache_control: { type: 'ephemeral' }, + }) + }) + it('passes systemPrompts as TextBlockParam[] for prompt caching support', async () => { const mockStream = (async function* () { yield {