diff --git a/.changeset/tidy-pdfs.md b/.changeset/tidy-pdfs.md new file mode 100644 index 0000000000..4f417471a4 --- /dev/null +++ b/.changeset/tidy-pdfs.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-anthropic': patch +--- + +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 b9511d7069..ec3ba259e1 100644 --- a/packages/ai-anthropic/src/adapters/text.ts +++ b/packages/ai-anthropic/src/adapters/text.ts @@ -660,11 +660,14 @@ export class AnthropicTextAdapter< return { type: 'image', source: imageSource, - ...metadata, + ...(metadata?.cache_control !== undefined && { + cache_control: metadata.cache_control, + }), } } 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 +682,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..fec7615383 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' @@ -25,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 /** @@ -57,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 /** @@ -68,8 +63,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..75be66c072 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,103 @@ 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('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 {