From db30f1e64e1adfa92d8178fac1a387cfbcfd47c2 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 13 Jan 2026 13:29:46 +0000 Subject: [PATCH 1/9] feat(js/plugins/anthropic): add support for citations --- js/plugins/anthropic/src/index.ts | 45 ++++- js/plugins/anthropic/src/runner/beta.ts | 22 ++- .../anthropic/src/runner/converters/beta.ts | 84 +++++++++ .../anthropic/src/runner/converters/shared.ts | 106 ++++++++++- .../anthropic/src/runner/converters/stable.ts | 83 +++++++++ js/plugins/anthropic/src/runner/stable.ts | 27 ++- js/plugins/anthropic/src/types.ts | 93 ++++++++++ js/plugins/anthropic/tests/live_test.ts | 171 +++++++++++++++++- .../anthropic/tests/stable_runner_test.ts | 100 ++++++++++ js/testapps/anthropic/package.json | 1 + js/testapps/anthropic/src/beta/citations.ts | 114 ++++++++++++ js/testapps/anthropic/src/beta/files_api.ts | 95 +++++----- 12 files changed, 884 insertions(+), 57 deletions(-) create mode 100644 js/testapps/anthropic/src/beta/citations.ts diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index d5a0fef9cb..1c4986c680 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -18,6 +18,7 @@ import Anthropic from '@anthropic-ai/sdk'; import { genkitPluginV2, type GenkitPluginV2 } from 'genkit/plugin'; +import type { Part } from 'genkit'; import { ActionMetadata, ModelReference, z } from 'genkit'; import { ModelAction } from 'genkit/model'; import { ActionType } from 'genkit/registry'; @@ -31,7 +32,15 @@ import { claudeModel, claudeModelReference, } from './models.js'; -import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; +import { + InternalPluginOptions, + PluginOptions, + __testClient, + type AnthropicDocumentOptions, +} from './types.js'; + +// Re-export citation type for consumers (AnthropicDocumentOptions is inferred via anthropicDocument()) +export type { AnthropicCitation } from './types.js'; const PROMPT_CACHING_BETA_HEADER_VALUE = 'prompt-caching-2024-07-31'; @@ -152,4 +161,38 @@ export const anthropic = anthropicPlugin as AnthropicPlugin; return claudeModelReference(name, config); }; +/** + * Creates a custom part representing an Anthropic document with optional citations support. + * + * Use this to provide documents to Claude that can be cited in responses. + * Citations must be enabled on all or none of the documents in a request. + * + * @example + * ```ts + * import { anthropic, anthropicDocument } from '@genkit-ai/anthropic'; + * + * const { text } = await ai.generate({ + * model: anthropic.model('claude-sonnet-4-5'), + * messages: [{ + * role: 'user', + * content: [ + * anthropicDocument({ + * source: { type: 'text', data: 'The grass is green. The sky is blue.' }, + * title: 'Nature Facts', + * citations: { enabled: true } + * }), + * { text: 'What color is the grass?' } + * ] + * }] + * }); + * ``` + */ +export function anthropicDocument(options: AnthropicDocumentOptions): Part { + return { + custom: { + anthropicDocument: options, + }, + }; +} + export default anthropic; diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 4efcd1fd13..fd0dbec86c 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -43,14 +43,20 @@ import type { import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; -import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; +import { + AnthropicConfigSchema, + type AnthropicDocumentOptions, + type ClaudeRunnerParams, +} from '../types.js'; import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; import { betaServerToolUseBlockToPart, + toBetaDocumentBlock, unsupportedServerToolError, } from './converters/beta.js'; import { + citationsDeltaToPart, inputJsonDeltaError, redactedThinkingBlockToPart, textBlockToPart, @@ -182,6 +188,13 @@ export class BetaRunner extends BaseRunner { return { type: 'text', text: part.text }; } + // Custom document (for citations support) + if (part.custom?.anthropicDocument) { + return toBetaDocumentBlock( + part.custom.anthropicDocument as AnthropicDocumentOptions + ); + } + // Media if (part.media) { if (part.media.contentType === 'anthropic/file') { @@ -463,6 +476,13 @@ export class BetaRunner extends BaseRunner { if (event.delta.type === 'thinking_delta') { return thinkingDeltaToPart(event.delta); } + if (event.delta.type === 'citations_delta') { + return citationsDeltaToPart( + event.delta as { + citation: Parameters[0]['citation']; + } + ); + } if (event.delta.type === 'input_json_delta') { throw inputJsonDeltaError(); } diff --git a/js/plugins/anthropic/src/runner/converters/beta.ts b/js/plugins/anthropic/src/runner/converters/beta.ts index c597dbb848..e9e1c43b81 100644 --- a/js/plugins/anthropic/src/runner/converters/beta.ts +++ b/js/plugins/anthropic/src/runner/converters/beta.ts @@ -18,7 +18,9 @@ * Converters for beta API content blocks. */ +import type { BetaRequestDocumentBlock } from '@anthropic-ai/sdk/resources/beta/messages'; import type { Part } from 'genkit'; +import type { AnthropicDocumentOptions } from '../../types.js'; /** * Converts a server_tool_use block to a Genkit Part. @@ -52,3 +54,85 @@ export function betaServerToolUseBlockToPart(block: { export function unsupportedServerToolError(blockType: string): string { return `Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.`; } + +/** + * Converts AnthropicDocumentOptions to Anthropic's beta API document block format. + */ +export function toBetaDocumentBlock( + options: AnthropicDocumentOptions +): BetaRequestDocumentBlock { + const block: BetaRequestDocumentBlock = { + type: 'document', + source: toBetaDocumentSource(options.source), + }; + + if (options.title) { + block.title = options.title; + } + if (options.context) { + block.context = options.context; + } + if (options.citations) { + block.citations = options.citations; + } + + return block; +} + +/** + * Converts document source options to Anthropic's beta API source format. + * The beta API supports file-based sources via the Files API. + */ +function toBetaDocumentSource( + source: AnthropicDocumentOptions['source'] +): BetaRequestDocumentBlock['source'] { + switch (source.type) { + case 'text': + return { + type: 'text', + media_type: (source.mediaType ?? 'text/plain') as 'text/plain', + data: source.data, + }; + case 'base64': + return { + type: 'base64', + media_type: source.mediaType as 'application/pdf', + data: source.data, + }; + case 'file': + return { + type: 'file', + file_id: source.fileId, + }; + case 'content': + return { + type: 'content', + content: source.content.map((item) => { + if (item.type === 'text') { + return item; + } + return { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: item.source.mediaType as + | 'image/jpeg' + | 'image/png' + | 'image/gif' + | 'image/webp', + data: item.source.data, + }, + }; + }), + }; + case 'url': + return { + type: 'url', + url: source.url, + }; + default: + throw new Error( + `Unsupported document source type: ${(source as { type: string }).type}` + ); + } +} diff --git a/js/plugins/anthropic/src/runner/converters/shared.ts b/js/plugins/anthropic/src/runner/converters/shared.ts index 6d6faf6091..7e98625965 100644 --- a/js/plugins/anthropic/src/runner/converters/shared.ts +++ b/js/plugins/anthropic/src/runner/converters/shared.ts @@ -20,11 +20,93 @@ */ import type { Part } from 'genkit'; +import type { AnthropicCitation } from '../../types.js'; + +/** Structural type for Anthropic citations (works with both stable and beta APIs). */ +interface AnthropicCitationInput { + type: string; + cited_text: string; + // document_index is optional since web search citations don't have it + document_index?: number; + document_title?: string | null; + file_id?: string | null; + start_char_index?: number; + end_char_index?: number; + start_page_number?: number; + end_page_number?: number; + start_block_index?: number; + end_block_index?: number; +} + +/** + * Converts Anthropic's citation format (snake_case) to genkit format (camelCase). + * Only handles document-based citations (char_location, page_location, content_block_location). + * Skips web search and other citation types that don't reference documents. + */ +export function fromAnthropicCitation( + citation: AnthropicCitationInput +): AnthropicCitation | undefined { + // Skip citations without document_index (e.g., web search results) + if (citation.document_index === undefined) { + return undefined; + } + + switch (citation.type) { + case 'char_location': + return { + type: 'char_location', + citedText: citation.cited_text, + documentIndex: citation.document_index, + documentTitle: citation.document_title ?? undefined, + fileId: citation.file_id ?? undefined, + startCharIndex: citation.start_char_index!, + endCharIndex: citation.end_char_index!, + }; + case 'page_location': + return { + type: 'page_location', + citedText: citation.cited_text, + documentIndex: citation.document_index, + documentTitle: citation.document_title ?? undefined, + fileId: citation.file_id ?? undefined, + startPageNumber: citation.start_page_number!, + endPageNumber: citation.end_page_number!, + }; + case 'content_block_location': + return { + type: 'content_block_location', + citedText: citation.cited_text, + documentIndex: citation.document_index, + documentTitle: citation.document_title ?? undefined, + fileId: citation.file_id ?? undefined, + startBlockIndex: citation.start_block_index!, + endBlockIndex: citation.end_block_index!, + }; + default: + // Skip web search and other citation types - they're not from documents + return undefined; + } +} /** - * Converts a text block to a Genkit Part. + * Converts a text block to a Genkit Part, including citations if present. + * Uses structural typing for compatibility with both stable and beta APIs. */ -export function textBlockToPart(block: { text: string }): Part { +export function textBlockToPart(block: { + text: string; + citations?: AnthropicCitationInput[] | null; +}): Part { + if (block.citations && block.citations.length > 0) { + const citations = block.citations + .map((c) => fromAnthropicCitation(c)) + .filter((c): c is AnthropicCitation => c !== undefined); + if (citations.length > 0) { + return { + text: block.text, + metadata: { citations }, + }; + } + } return { text: block.text }; } @@ -103,6 +185,26 @@ export function thinkingDeltaToPart(delta: { thinking: string }): Part { return { reasoning: delta.thinking }; } +/** + * Converts a citations_delta to a Genkit Part for streaming. + * Returns a text part with empty text and citation data in metadata. + * Empty text is intentional: genkit's `.text` getter concatenates all text parts, + * so empty strings contribute nothing to the final text while preserving the citation + * in the parts array for consumers who need to access citation metadata. + */ +export function citationsDeltaToPart(delta: { + citation: AnthropicCitationInput; +}): Part | undefined { + const citation = fromAnthropicCitation(delta.citation); + if (citation) { + return { + text: '', + metadata: { citations: [citation] }, + }; + } + return undefined; +} + /** * Error for unsupported input_json_delta in streaming. */ diff --git a/js/plugins/anthropic/src/runner/converters/stable.ts b/js/plugins/anthropic/src/runner/converters/stable.ts index d6b3508d42..5de9221a12 100644 --- a/js/plugins/anthropic/src/runner/converters/stable.ts +++ b/js/plugins/anthropic/src/runner/converters/stable.ts @@ -18,7 +18,9 @@ * Converters for stable API content blocks. */ +import type { DocumentBlockParam } from '@anthropic-ai/sdk/resources/messages'; import type { Part } from 'genkit'; +import type { AnthropicDocumentOptions } from '../../types.js'; /** * Converts a server_tool_use block to a Genkit Part. @@ -40,3 +42,84 @@ export function serverToolUseBlockToPart(block: { }, }; } + +/** + * Converts AnthropicDocumentOptions to Anthropic's stable API document block format. + */ +export function toDocumentBlock( + options: AnthropicDocumentOptions +): DocumentBlockParam { + const block: DocumentBlockParam = { + type: 'document', + source: toDocumentSource(options.source), + }; + + if (options.title) { + block.title = options.title; + } + if (options.context) { + block.context = options.context; + } + if (options.citations) { + block.citations = options.citations; + } + + return block; +} + +/** + * Converts document source options to Anthropic's stable API source format. + * Note: The stable API does not support file-based sources (Files API). + */ +function toDocumentSource( + source: AnthropicDocumentOptions['source'] +): DocumentBlockParam['source'] { + switch (source.type) { + case 'text': + return { + type: 'text', + media_type: (source.mediaType ?? 'text/plain') as 'text/plain', + data: source.data, + }; + case 'base64': + return { + type: 'base64', + media_type: source.mediaType as 'application/pdf', + data: source.data, + }; + case 'file': + throw new Error( + 'File-based document sources require the beta API. Set apiVersion: "beta" in your plugin config or request config.' + ); + case 'content': + return { + type: 'content', + content: source.content.map((item) => { + if (item.type === 'text') { + return item; + } + return { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: item.source.mediaType as + | 'image/jpeg' + | 'image/png' + | 'image/gif' + | 'image/webp', + data: item.source.data, + }, + }; + }), + }; + case 'url': + return { + type: 'url', + url: source.url, + }; + default: + throw new Error( + `Unsupported document source type: ${(source as { type: string }).type}` + ); + } +} diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 61c921b97c..ea2b427ad9 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -41,10 +41,15 @@ import type { import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; -import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; +import { + AnthropicConfigSchema, + type AnthropicDocumentOptions, + type ClaudeRunnerParams, +} from '../types.js'; import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; import { + citationsDeltaToPart, inputJsonDeltaError, redactedThinkingBlockToPart, textBlockToPart, @@ -54,7 +59,10 @@ import { toolUseBlockToPart, webSearchToolResultBlockToPart, } from './converters/shared.js'; -import { serverToolUseBlockToPart } from './converters/stable.js'; +import { + serverToolUseBlockToPart, + toDocumentBlock, +} from './converters/stable.js'; import { RunnerTypes as BaseRunnerTypes } from './types.js'; interface RunnerTypes extends BaseRunnerTypes { @@ -121,6 +129,13 @@ export class Runner extends BaseRunner { }; } + // Custom document (for citations support) + if (part.custom?.anthropicDocument) { + return toDocumentBlock( + part.custom.anthropicDocument as AnthropicDocumentOptions + ); + } + if (part.media) { if (part.media.contentType === 'application/pdf') { return { @@ -355,6 +370,14 @@ export class Runner extends BaseRunner { return thinkingDeltaToPart(delta); } + if (delta.type === 'citations_delta') { + return citationsDeltaToPart( + delta as { + citation: Parameters[0]['citation']; + } + ); + } + if (delta.type === 'input_json_delta') { throw inputJsonDeltaError(); } diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 2f61464a10..77744be2c1 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -195,3 +195,96 @@ export function resolveBetaEnabled( if (pluginDefaultApiVersion === 'beta') return true; return false; } + +/** Plain text document source. */ +export interface AnthropicTextSource { + type: 'text'; + data: string; + mediaType?: string; +} + +/** Base64-encoded document source (e.g., PDF). */ +export interface AnthropicBase64Source { + type: 'base64'; + data: string; + mediaType: string; +} + +/** File reference source (from Files API). */ +export interface AnthropicFileSource { + type: 'file'; + fileId: string; +} + +/** Custom content blocks for granular citation control. */ +export interface AnthropicContentSource { + type: 'content'; + content: Array< + | { type: 'text'; text: string } + | { + type: 'image'; + source: { type: 'base64'; mediaType: string; data: string }; + } + >; +} + +/** URL source for PDFs. */ +export interface AnthropicURLSource { + type: 'url'; + url: string; +} + +/** Union of all document source types. */ +export type AnthropicDocumentSource = + | AnthropicTextSource + | AnthropicBase64Source + | AnthropicFileSource + | AnthropicContentSource + | AnthropicURLSource; + +/** Options for creating an Anthropic document with optional citations. */ +export interface AnthropicDocumentOptions { + source: AnthropicDocumentSource; + title?: string; + context?: string; + citations?: { enabled: boolean }; +} + +/** Citation from a plain text document (character indices). */ +export interface CharLocationCitation { + type: 'char_location'; + citedText: string; + documentIndex: number; + documentTitle?: string; + fileId?: string; + startCharIndex: number; + endCharIndex: number; +} + +/** Citation from a PDF document (page numbers). */ +export interface PageLocationCitation { + type: 'page_location'; + citedText: string; + documentIndex: number; + documentTitle?: string; + fileId?: string; + startPageNumber: number; + endPageNumber: number; +} + +/** Citation from a custom content document (block indices). */ +export interface ContentBlockLocationCitation { + type: 'content_block_location'; + citedText: string; + documentIndex: number; + documentTitle?: string; + fileId?: string; + startBlockIndex: number; + endBlockIndex: number; +} + +/** Union of all citation types for documents. */ +export type AnthropicCitation = + | CharLocationCitation + | PageLocationCitation + | ContentBlockLocationCitation; diff --git a/js/plugins/anthropic/tests/live_test.ts b/js/plugins/anthropic/tests/live_test.ts index 0c370196dd..4d699908bb 100644 --- a/js/plugins/anthropic/tests/live_test.ts +++ b/js/plugins/anthropic/tests/live_test.ts @@ -24,7 +24,11 @@ import * as assert from 'assert'; import { genkit, z } from 'genkit'; import { describe, it } from 'node:test'; -import { anthropic } from '../src/index.js'; +import { + anthropic, + anthropicDocument, + type AnthropicCitation, +} from '../src/index.js'; const API_KEY = process.env.ANTHROPIC_API_KEY; @@ -126,4 +130,169 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); } }); + + it('should return citations from a plain text document', async () => { + const ai = genkit({ + plugins: [anthropic({ apiKey: API_KEY, apiVersion: 'beta' })], + }); + + const result = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'text', + data: 'The grass is green. The sky is blue. Water is wet.', + }, + title: 'Basic Facts', + citations: { enabled: true }, + }), + { text: 'What color is the grass? Cite your source.' }, + ], + }, + ], + }); + + assert.ok(result.text, 'Should have response text'); + assert.ok( + result.text.toLowerCase().includes('green'), + 'Response should mention green' + ); + + // Extract citations from response parts + const citations = result.message?.content + .filter((part) => part.metadata?.citations) + .flatMap( + (part) => part.metadata?.citations as AnthropicCitation[] | undefined + ) + .filter((c): c is AnthropicCitation => c !== undefined); + + assert.ok( + citations && citations.length > 0, + 'Should have at least one citation' + ); + + // Verify citation structure + const citation = citations[0]; + assert.strictEqual( + citation.type, + 'char_location', + 'Should be a char_location citation' + ); + assert.ok(citation.citedText, 'Citation should have cited text'); + assert.strictEqual( + citation.documentIndex, + 0, + 'Should reference first document' + ); + }); + + it('should return citations with streaming enabled', async () => { + const ai = genkit({ + plugins: [anthropic({ apiKey: API_KEY, apiVersion: 'beta' })], + }); + + const streamedChunks: string[] = []; + + const result = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'text', + data: 'Cats are mammals. Dogs are also mammals. Birds have feathers.', + }, + title: 'Animal Facts', + citations: { enabled: true }, + }), + { text: 'Are cats mammals? Cite your source.' }, + ], + }, + ], + streamingCallback: (chunk) => { + if (chunk.text) { + streamedChunks.push(chunk.text); + } + }, + }); + + assert.ok(result.text, 'Should have response text'); + assert.ok( + streamedChunks.length > 0, + 'Should have received streaming chunks' + ); + + // Extract citations from final response + const citations = result.message?.content + .filter((part) => part.metadata?.citations) + .flatMap( + (part) => part.metadata?.citations as AnthropicCitation[] | undefined + ) + .filter((c): c is AnthropicCitation => c !== undefined); + + assert.ok( + citations && citations.length > 0, + 'Should have at least one citation' + ); + }); + + it('should return citations using stable API', async () => { + const ai = genkit({ + plugins: [anthropic({ apiKey: API_KEY })], // No apiVersion = stable + }); + + const result = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'text', + data: 'The ocean is blue. The sun is yellow. Snow is white.', + }, + title: 'Color Facts', + citations: { enabled: true }, + }), + { text: 'What color is the ocean? Cite your source.' }, + ], + }, + ], + }); + + assert.ok(result.text, 'Should have response text'); + assert.ok( + result.text.toLowerCase().includes('blue'), + 'Response should mention blue' + ); + + // Extract citations from response parts + const citations = result.message?.content + .filter((part) => part.metadata?.citations) + .flatMap( + (part) => part.metadata?.citations as AnthropicCitation[] | undefined + ) + .filter((c): c is AnthropicCitation => c !== undefined); + + assert.ok( + citations && citations.length > 0, + 'Should have at least one citation with stable API' + ); + + // Verify citation structure + const citation = citations[0]; + assert.strictEqual( + citation.type, + 'char_location', + 'Should be a char_location citation' + ); + assert.ok(citation.citedText, 'Citation should have cited text'); + }); }); diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 28e1834e2a..762d190d80 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -724,6 +724,40 @@ describe('fromAnthropicContentBlockChunk', () => { }, expectedOutput: { reasoning: 'Step by step...' }, }, + { + should: 'should return citation part from citations_delta event', + event: { + index: 0, + type: 'content_block_delta', + delta: { + type: 'citations_delta', + citation: { + type: 'char_location', + cited_text: 'The grass is green.', + document_index: 0, + document_title: 'Basic Facts', + start_char_index: 0, + end_char_index: 19, + }, + }, + } as MessageStreamEvent, + expectedOutput: { + text: '', + metadata: { + citations: [ + { + type: 'char_location', + citedText: 'The grass is green.', + documentIndex: 0, + documentTitle: 'Basic Facts', + fileId: undefined, + startCharIndex: 0, + endCharIndex: 19, + }, + ], + }, + }, + }, { should: 'should return tool use requests', event: { @@ -914,6 +948,72 @@ describe('fromAnthropicResponse', () => { }, }, }, + { + should: 'should work with text content containing citations', + message: { + id: 'abc123', + model: 'whatever', + type: 'message', + role: 'assistant', + stop_reason: 'end_turn', + stop_sequence: null, + content: [ + { + type: 'text', + text: 'The grass is green.', + citations: [ + { + type: 'char_location', + cited_text: 'The grass is green.', + document_index: 0, + document_title: 'Basic Facts', + start_char_index: 0, + end_char_index: 19, + }, + ], + }, + ], + usage: createUsage({ + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }), + } as Message, + expectedOutput: { + candidates: [ + { + index: 0, + finishReason: 'stop', + message: { + role: 'model', + content: [ + { + text: 'The grass is green.', + metadata: { + citations: [ + { + type: 'char_location', + citedText: 'The grass is green.', + documentIndex: 0, + documentTitle: 'Basic Facts', + fileId: undefined, + startCharIndex: 0, + endCharIndex: 19, + }, + ], + }, + }, + ], + }, + }, + ], + usage: { + inputTokens: 10, + outputTokens: 20, + }, + }, + }, ]; for (const test of testCases) { diff --git a/js/testapps/anthropic/package.json b/js/testapps/anthropic/package.json index 0f7ac15001..7d7aff6664 100644 --- a/js/testapps/anthropic/package.json +++ b/js/testapps/anthropic/package.json @@ -14,6 +14,7 @@ "dev:beta:files-api": "genkit start -- npx tsx --watch src/beta/files_api.ts", "dev:beta:effort": "genkit start -- npx tsx --watch src/beta/effort.ts", "dev:beta:additional-params": "genkit start -- npx tsx --watch src/beta/additional_params.ts", + "dev:beta:citations": "genkit start -- npx tsx --watch src/beta/citations.ts", "dev:stable:text-plain": "genkit start -- npx tsx --watch src/stable/text-plain.ts", "dev:stable:webp": "genkit start -- npx tsx --watch src/stable/webp.ts", "dev:stable:pdf": "genkit start -- npx tsx --watch src/stable/pdf.ts", diff --git a/js/testapps/anthropic/src/beta/citations.ts b/js/testapps/anthropic/src/beta/citations.ts new file mode 100644 index 0000000000..a19c0b0256 --- /dev/null +++ b/js/testapps/anthropic/src/beta/citations.ts @@ -0,0 +1,114 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { anthropic, anthropicDocument } from '@genkit-ai/anthropic'; +import { genkit } from 'genkit'; + +const ai = genkit({ + plugins: [ + anthropic({ + apiVersion: 'beta', + apiKey: process.env.ANTHROPIC_API_KEY, + }), + ], +}); + +/** + * This flow demonstrates citations with a plain text document. + * The response will include citations pointing back to the source text. + */ +ai.defineFlow('citations-text', async () => { + const response = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'text', + data: 'The grass is green. The sky is blue. Water is wet. Fire is hot.', + }, + title: 'Basic Facts', + citations: { enabled: true }, + }), + { + text: 'What color is the grass and sky? Please cite your sources.', + }, + ], + }, + ], + }); + + // Log the response with citations + console.log('Response text:', response.text); + console.log( + 'Response content:', + JSON.stringify(response.message?.content, null, 2) + ); + + // Extract citations from the response + const citations = response.message?.content + .filter((part) => part.metadata?.citations) + .flatMap((part) => part.metadata?.citations); + + console.log('Citations:', JSON.stringify(citations, null, 2)); + + return { + text: response.text, + citations, + }; +}); + +/** + * This flow demonstrates citations with custom content blocks + * for more granular citation control. + */ +ai.defineFlow('citations-custom-content', async () => { + const response = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'content', + content: [ + { type: 'text', text: 'Fact 1: Dogs are mammals.' }, + { type: 'text', text: 'Fact 2: Cats are also mammals.' }, + { type: 'text', text: 'Fact 3: Birds have feathers.' }, + ], + }, + title: 'Animal Facts', + citations: { enabled: true }, + }), + { + text: 'What do dogs and cats have in common? Cite your source.', + }, + ], + }, + ], + }); + + console.log('Response text:', response.text); + console.log( + 'Response content:', + JSON.stringify(response.message?.content, null, 2) + ); + + return response.text; +}); diff --git a/js/testapps/anthropic/src/beta/files_api.ts b/js/testapps/anthropic/src/beta/files_api.ts index 3dd6b08d01..d74f96a007 100644 --- a/js/testapps/anthropic/src/beta/files_api.ts +++ b/js/testapps/anthropic/src/beta/files_api.ts @@ -24,6 +24,10 @@ const API_KEY = process.env.ANTHROPIC_API_KEY; // If you have a file ID, you can set it here. Otherwise, the flow will upload a new PDF to Anthropic. const FILE_ID = process.env.ANTHROPIC_FILE_ID; +/** + * Uploads a PDF file to Anthropic's Files API. + * @returns The file object containing `id` field. + */ export async function uploadPdfToAnthropic() { if (!API_KEY) throw new Error('Missing ANTHROPIC_API_KEY env variable'); @@ -34,7 +38,7 @@ export async function uploadPdfToAnthropic() { const form = new FormData(); form.append( 'file', - new Blob([fileBuffer], { type: 'application/pdf' }), + new Blob([new Uint8Array(fileBuffer)], { type: 'application/pdf' }), 'attention-first-page.pdf' ); @@ -53,62 +57,53 @@ export async function uploadPdfToAnthropic() { throw new Error(`Anthropic file upload failed: ${response.status} ${text}`); } const result = await response.json(); - return result as { id: string }; // Contains 'file_id', etc. + return result as { id: string }; } -async function main() { - const ai = genkit({ - plugins: [ - // Default all flows in this sample to the beta surface - anthropic({ - apiVersion: 'beta', - apiKey: API_KEY, - }), - ], - }); +const ai = genkit({ + plugins: [ + // Default all flows in this sample to the beta surface + anthropic({ + apiVersion: 'beta', + apiKey: API_KEY, + }), + ], +}); - /** - * This flow demonstrates PDF document processing via a public data URL along with a user prompt. - * The PDF is sent as a media part with the correct contentType and a URL, not base64. - */ - ai.defineFlow('beta-pdf-url', async () => { - let fileId = FILE_ID; +/** + * This flow demonstrates PDF document processing via the Anthropic Files API. + * The PDF is uploaded to Anthropic and referenced by file ID. + */ +ai.defineFlow('beta-pdf-url', async () => { + let fileId = FILE_ID; - if (!fileId) { - const fileResult = await uploadPdfToAnthropic(); - if (!fileResult || !fileResult.id) { - throw new Error('File ID not found'); - } - fileId = fileResult.id; + if (!fileId) { + const fileResult = await uploadPdfToAnthropic(); + if (!fileResult || !fileResult.id) { + throw new Error('File ID not found'); } + fileId = fileResult.id; + } - // Example: Use a (demo/test) PDF file accessible via public URL. - // Replace this with your actual PDF if needed. - const { text } = await ai.generate({ - model: anthropic.model('claude-sonnet-4-5'), - messages: [ - { - role: 'user', - content: [ - { - text: 'What are the key findings or main points in this document?', - }, - { - media: { - url: fileId, - contentType: 'anthropic/file', - }, + const { text } = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + { + text: 'What are the key findings or main points in this document?', + }, + { + media: { + url: fileId, + contentType: 'anthropic/file', }, - ], - }, - ], - }); - - return text; + }, + ], + }, + ], }); -} -main().catch((error) => { - console.error('Error:', error); - process.exit(1); + return text; }); From 7f0a5b9951531e1420d48bc75bb7bc4a337a76ba Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 13 Jan 2026 13:42:58 +0000 Subject: [PATCH 2/9] test(js/plugins/anthropic): add more citations tests --- .../anthropic/tests/beta_runner_test.ts | 315 ++++++++++++++++++ .../anthropic/tests/stable_runner_test.ts | 187 +++++++++++ 2 files changed, 502 insertions(+) diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 98e4226e86..a3eb5ae3c4 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -801,4 +801,319 @@ describe('BetaRunner', () => { const nullReason = exposed.fromBetaStopReason(null); assert.strictEqual(nullReason, 'unknown'); }); + + it('should convert citations_delta to citation part', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const citationPart = (runner as any).toGenkitPart({ + type: 'content_block_delta', + index: 0, + delta: { + type: 'citations_delta', + citation: { + type: 'char_location', + cited_text: 'The sky is blue.', + document_index: 0, + document_title: 'Facts', + start_char_index: 0, + end_char_index: 16, + }, + }, + }); + + assert.deepStrictEqual(citationPart, { + text: '', + metadata: { + citations: [ + { + type: 'char_location', + citedText: 'The sky is blue.', + documentIndex: 0, + documentTitle: 'Facts', + fileId: undefined, + startCharIndex: 0, + endCharIndex: 16, + }, + ], + }, + }); + }); + + it('should convert text block with citations', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const textPart = (runner as any).fromBetaContentBlock({ + type: 'text', + text: 'The answer is green.', + citations: [ + { + type: 'char_location', + cited_text: 'grass is green', + document_index: 0, + document_title: 'Nature', + start_char_index: 5, + end_char_index: 19, + }, + ], + }); + + assert.deepStrictEqual(textPart, { + text: 'The answer is green.', + metadata: { + citations: [ + { + type: 'char_location', + citedText: 'grass is green', + documentIndex: 0, + documentTitle: 'Nature', + fileId: undefined, + startCharIndex: 5, + endCharIndex: 19, + }, + ], + }, + }); + }); + + it('should convert page_location citations from PDF documents', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const textPart = (runner as any).fromBetaContentBlock({ + type: 'text', + text: 'According to the document...', + citations: [ + { + type: 'page_location', + cited_text: 'Important finding', + document_index: 0, + document_title: 'Report.pdf', + start_page_number: 1, + end_page_number: 2, + }, + ], + }); + + assert.deepStrictEqual(textPart, { + text: 'According to the document...', + metadata: { + citations: [ + { + type: 'page_location', + citedText: 'Important finding', + documentIndex: 0, + documentTitle: 'Report.pdf', + fileId: undefined, + startPageNumber: 1, + endPageNumber: 2, + }, + ], + }, + }); + }); + + it('should convert content_block_location citations', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const textPart = (runner as any).fromBetaContentBlock({ + type: 'text', + text: 'Based on the content...', + citations: [ + { + type: 'content_block_location', + cited_text: 'Block content', + document_index: 1, + document_title: 'Structured Doc', + start_block_index: 0, + end_block_index: 2, + }, + ], + }); + + assert.deepStrictEqual(textPart, { + text: 'Based on the content...', + metadata: { + citations: [ + { + type: 'content_block_location', + citedText: 'Block content', + documentIndex: 1, + documentTitle: 'Structured Doc', + fileId: undefined, + startBlockIndex: 0, + endBlockIndex: 2, + }, + ], + }, + }); + }); + + it('should skip web search citations (no document_index)', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const textPart = (runner as any).fromBetaContentBlock({ + type: 'text', + text: 'Search results say...', + citations: [ + { + type: 'web_search_result_location', + cited_text: 'From the web', + url: 'https://example.com', + title: 'Example Page', + // Note: no document_index - this is a web search citation + }, + ], + }); + + // Should return text without citations metadata since web search citations are filtered + assert.deepStrictEqual(textPart, { + text: 'Search results say...', + }); + }); + + it('should convert anthropicDocument custom part to beta document block', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const docBlock = (runner as any).toAnthropicMessageContent({ + custom: { + anthropicDocument: { + source: { + type: 'text', + data: 'Document content here.', + }, + title: 'My Document', + citations: { enabled: true }, + }, + }, + }); + + assert.strictEqual(docBlock.type, 'document'); + assert.strictEqual(docBlock.title, 'My Document'); + assert.deepStrictEqual(docBlock.citations, { enabled: true }); + assert.strictEqual(docBlock.source.type, 'text'); + assert.strictEqual(docBlock.source.data, 'Document content here.'); + }); + + it('should convert file source in beta document block', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const docBlock = (runner as any).toAnthropicMessageContent({ + custom: { + anthropicDocument: { + source: { + type: 'file', + fileId: 'file-abc123', + }, + title: 'Uploaded File', + }, + }, + }); + + assert.strictEqual(docBlock.type, 'document'); + assert.strictEqual(docBlock.source.type, 'file'); + assert.strictEqual(docBlock.source.file_id, 'file-abc123'); + }); + + it('should convert URL source in beta document block', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const docBlock = (runner as any).toAnthropicMessageContent({ + custom: { + anthropicDocument: { + source: { + type: 'url', + url: 'https://example.com/doc.pdf', + }, + }, + }, + }); + + assert.strictEqual(docBlock.type, 'document'); + assert.strictEqual(docBlock.source.type, 'url'); + assert.strictEqual(docBlock.source.url, 'https://example.com/doc.pdf'); + }); + + it('should convert base64 source in beta document block', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const docBlock = (runner as any).toAnthropicMessageContent({ + custom: { + anthropicDocument: { + source: { + type: 'base64', + data: 'JVBERi0xLjQ=', + mediaType: 'application/pdf', + }, + }, + }, + }); + + assert.strictEqual(docBlock.type, 'document'); + assert.strictEqual(docBlock.source.type, 'base64'); + assert.strictEqual(docBlock.source.data, 'JVBERi0xLjQ='); + assert.strictEqual(docBlock.source.media_type, 'application/pdf'); + }); + + it('should convert content source in beta document block', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const docBlock = (runner as any).toAnthropicMessageContent({ + custom: { + anthropicDocument: { + source: { + type: 'content', + content: [ + { type: 'text', text: 'First block' }, + { type: 'text', text: 'Second block' }, + ], + }, + citations: { enabled: true }, + }, + }, + }); + + assert.strictEqual(docBlock.type, 'document'); + assert.strictEqual(docBlock.source.type, 'content'); + assert.strictEqual(docBlock.source.content.length, 2); + assert.strictEqual(docBlock.source.content[0].text, 'First block'); + }); }); diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 762d190d80..1caf6f4187 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -33,6 +33,7 @@ import type { CandidateData, ToolDefinition } from 'genkit/model'; import { describe, it, mock } from 'node:test'; import { claudeModel, claudeRunner } from '../src/models.js'; +import { toDocumentBlock } from '../src/runner/converters/stable.js'; import { Runner } from '../src/runner/stable.js'; import { AnthropicConfigSchema } from '../src/types.js'; import { @@ -2259,6 +2260,192 @@ describe('BaseRunner helper utilities', () => { }); }); +describe('toDocumentBlock (stable converter)', () => { + it('should convert text source to stable document block', () => { + const result = toDocumentBlock({ + source: { + type: 'text', + data: 'The grass is green. The sky is blue.', + }, + title: 'Basic Facts', + citations: { enabled: true }, + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'text', + media_type: 'text/plain', + data: 'The grass is green. The sky is blue.', + }, + title: 'Basic Facts', + citations: { enabled: true }, + }); + }); + + it('should convert base64 PDF source to stable document block', () => { + const result = toDocumentBlock({ + source: { + type: 'base64', + mediaType: 'application/pdf', + data: 'JVBERi0xLjQK', + }, + title: 'Test PDF', + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: 'JVBERi0xLjQK', + }, + title: 'Test PDF', + }); + }); + + it('should convert URL source to stable document block', () => { + const result = toDocumentBlock({ + source: { + type: 'url', + url: 'https://example.com/document.pdf', + }, + context: 'This is a PDF about science.', + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'url', + url: 'https://example.com/document.pdf', + }, + context: 'This is a PDF about science.', + }); + }); + + it('should convert content source with text blocks to stable document block', () => { + const result = toDocumentBlock({ + source: { + type: 'content', + content: [ + { type: 'text', text: 'Fact 1: Dogs are mammals.' }, + { type: 'text', text: 'Fact 2: Cats are also mammals.' }, + ], + }, + title: 'Animal Facts', + citations: { enabled: true }, + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'Fact 1: Dogs are mammals.' }, + { type: 'text', text: 'Fact 2: Cats are also mammals.' }, + ], + }, + title: 'Animal Facts', + citations: { enabled: true }, + }); + }); + + it('should convert content source with images to stable document block', () => { + const result = toDocumentBlock({ + source: { + type: 'content', + content: [ + { type: 'text', text: 'A picture of a cat:' }, + { + type: 'image', + source: { + type: 'base64', + mediaType: 'image/png', + data: 'iVBORw0KGgo=', + }, + }, + ], + }, + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'A picture of a cat:' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgo=', + }, + }, + ], + }, + }); + }); + + it('should throw error for file source on stable API', () => { + assert.throws( + () => + toDocumentBlock({ + source: { + type: 'file', + fileId: 'file-abc123', + }, + title: 'Uploaded Document', + }), + /File-based document sources require the beta API/ + ); + }); + + it('should handle anthropicDocument custom part via toAnthropicMessageContent', () => { + // Test that the stable runner correctly converts anthropicDocument custom parts + const result = testRunner.toAnthropicMessageContent({ + custom: { + anthropicDocument: { + source: { + type: 'text', + data: 'Hello world.', + }, + title: 'Test', + citations: { enabled: true }, + }, + }, + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'text', + media_type: 'text/plain', + data: 'Hello world.', + }, + title: 'Test', + citations: { enabled: true }, + }); + }); + + it('should throw for file source via toAnthropicMessageContent on stable API', () => { + assert.throws( + () => + testRunner.toAnthropicMessageContent({ + custom: { + anthropicDocument: { + source: { + type: 'file', + fileId: 'file-abc123', + }, + }, + }, + }), + /File-based document sources require the beta API/ + ); + }); +}); + describe('Runner request bodies and error branches', () => { it('should include optional config fields in non-streaming request body', () => { const mockClient = createMockAnthropicClient(); From 7e4c21a99acd432c3a0d7ec8823c5f04ce6b8a09 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 19 Jan 2026 13:30:36 +0000 Subject: [PATCH 3/9] fix(js/plugins/anthropic): fix citation structural types --- js/plugins/anthropic/src/runner/beta.ts | 6 +----- js/plugins/anthropic/src/runner/converters/shared.ts | 1 + js/plugins/anthropic/src/runner/stable.ts | 6 +----- 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index fd0dbec86c..69a7e41755 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -477,11 +477,7 @@ export class BetaRunner extends BaseRunner { return thinkingDeltaToPart(event.delta); } if (event.delta.type === 'citations_delta') { - return citationsDeltaToPart( - event.delta as { - citation: Parameters[0]['citation']; - } - ); + return citationsDeltaToPart(event.delta); } if (event.delta.type === 'input_json_delta') { throw inputJsonDeltaError(); diff --git a/js/plugins/anthropic/src/runner/converters/shared.ts b/js/plugins/anthropic/src/runner/converters/shared.ts index 7e98625965..28fcee7067 100644 --- a/js/plugins/anthropic/src/runner/converters/shared.ts +++ b/js/plugins/anthropic/src/runner/converters/shared.ts @@ -193,6 +193,7 @@ export function thinkingDeltaToPart(delta: { thinking: string }): Part { * in the parts array for consumers who need to access citation metadata. */ export function citationsDeltaToPart(delta: { + type: 'citations_delta'; citation: AnthropicCitationInput; }): Part | undefined { const citation = fromAnthropicCitation(delta.citation); diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index ea2b427ad9..7e9907778a 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -371,11 +371,7 @@ export class Runner extends BaseRunner { } if (delta.type === 'citations_delta') { - return citationsDeltaToPart( - delta as { - citation: Parameters[0]['citation']; - } - ); + return citationsDeltaToPart(delta); } if (delta.type === 'input_json_delta') { From fc50fc2676d83effa93a62ad6f3fdfdb4a088660 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 19 Jan 2026 13:52:23 +0000 Subject: [PATCH 4/9] refactor(js/plugins/anthropic): factor out shared citation logic and add more live tests --- .../anthropic/src/runner/converters/beta.ts | 86 +------ .../anthropic/src/runner/converters/shared.ts | 93 +++++++- .../anthropic/src/runner/converters/stable.ts | 76 +------ js/plugins/anthropic/tests/live_test.ts | 209 ++++++++++++++++++ 4 files changed, 319 insertions(+), 145 deletions(-) diff --git a/js/plugins/anthropic/src/runner/converters/beta.ts b/js/plugins/anthropic/src/runner/converters/beta.ts index e9e1c43b81..4b1fa49bb4 100644 --- a/js/plugins/anthropic/src/runner/converters/beta.ts +++ b/js/plugins/anthropic/src/runner/converters/beta.ts @@ -21,6 +21,7 @@ import type { BetaRequestDocumentBlock } from '@anthropic-ai/sdk/resources/beta/messages'; import type { Part } from 'genkit'; import type { AnthropicDocumentOptions } from '../../types.js'; +import { convertDocumentSource, createDocumentBlock } from './shared.js'; /** * Converts a server_tool_use block to a Genkit Part. @@ -57,82 +58,19 @@ export function unsupportedServerToolError(blockType: string): string { /** * Converts AnthropicDocumentOptions to Anthropic's beta API document block format. + * The beta API supports file-based sources via the Files API. */ export function toBetaDocumentBlock( options: AnthropicDocumentOptions ): BetaRequestDocumentBlock { - const block: BetaRequestDocumentBlock = { - type: 'document', - source: toBetaDocumentSource(options.source), - }; - - if (options.title) { - block.title = options.title; - } - if (options.context) { - block.context = options.context; - } - if (options.citations) { - block.citations = options.citations; - } - - return block; -} - -/** - * Converts document source options to Anthropic's beta API source format. - * The beta API supports file-based sources via the Files API. - */ -function toBetaDocumentSource( - source: AnthropicDocumentOptions['source'] -): BetaRequestDocumentBlock['source'] { - switch (source.type) { - case 'text': - return { - type: 'text', - media_type: (source.mediaType ?? 'text/plain') as 'text/plain', - data: source.data, - }; - case 'base64': - return { - type: 'base64', - media_type: source.mediaType as 'application/pdf', - data: source.data, - }; - case 'file': - return { - type: 'file', - file_id: source.fileId, - }; - case 'content': - return { - type: 'content', - content: source.content.map((item) => { - if (item.type === 'text') { - return item; - } - return { - type: 'image' as const, - source: { - type: 'base64' as const, - media_type: item.source.mediaType as - | 'image/jpeg' - | 'image/png' - | 'image/gif' - | 'image/webp', - data: item.source.data, - }, - }; - }), - }; - case 'url': - return { - type: 'url', - url: source.url, - }; - default: - throw new Error( - `Unsupported document source type: ${(source as { type: string }).type}` - ); - } + return createDocumentBlock(options, (source) => + convertDocumentSource( + source, + (fileId) => + ({ + type: 'file', + file_id: fileId, + }) as BetaRequestDocumentBlock['source'] + ) + ); } diff --git a/js/plugins/anthropic/src/runner/converters/shared.ts b/js/plugins/anthropic/src/runner/converters/shared.ts index 28fcee7067..8b19007f44 100644 --- a/js/plugins/anthropic/src/runner/converters/shared.ts +++ b/js/plugins/anthropic/src/runner/converters/shared.ts @@ -20,7 +20,10 @@ */ import type { Part } from 'genkit'; -import type { AnthropicCitation } from '../../types.js'; +import type { + AnthropicCitation, + AnthropicDocumentOptions, +} from '../../types.js'; /** Structural type for Anthropic citations (works with both stable and beta APIs). */ interface AnthropicCitationInput { @@ -214,3 +217,91 @@ export function inputJsonDeltaError(): Error { 'Anthropic streaming tool input (input_json_delta) is not yet supported. Please disable streaming or upgrade this plugin.' ); } + +// --- Document block converters (shared between stable and beta APIs) --- + +/** + * Document block type constraint for generics. + */ +type DocumentBlockBase = { + type: 'document'; + source: unknown; + title?: string | null; + context?: string | null; + citations?: { enabled?: boolean } | null; +}; + +/** + * Converts AnthropicDocumentOptions to Anthropic's document block format. + * Works for both stable and beta APIs via generics. + */ +export function createDocumentBlock( + options: AnthropicDocumentOptions, + sourceConverter: (source: AnthropicDocumentOptions['source']) => T['source'] +): T { + return { + type: 'document' as const, + source: sourceConverter(options.source), + ...(options.title && { title: options.title }), + ...(options.context && { context: options.context }), + ...(options.citations && { citations: options.citations }), + } as T; +} + +/** + * Converts document source options to Anthropic's source format. + * Works for both stable and beta APIs via a file handler callback. + * The file handler is called for 'file' type sources, allowing different + * behavior (error for stable, conversion for beta). + */ +export function convertDocumentSource( + source: AnthropicDocumentOptions['source'], + fileHandler: (fileId: string) => T +): T { + switch (source.type) { + case 'text': + return { + type: 'text', + media_type: (source.mediaType ?? 'text/plain') as 'text/plain', + data: source.data, + } as T; + case 'base64': + return { + type: 'base64', + media_type: source.mediaType as 'application/pdf', + data: source.data, + } as T; + case 'file': + return fileHandler(source.fileId); + case 'content': + return { + type: 'content', + content: source.content.map((item) => { + if (item.type === 'text') { + return item; + } + return { + type: 'image' as const, + source: { + type: 'base64' as const, + media_type: item.source.mediaType as + | 'image/jpeg' + | 'image/png' + | 'image/gif' + | 'image/webp', + data: item.source.data, + }, + }; + }), + } as T; + case 'url': + return { + type: 'url', + url: source.url, + } as T; + default: + throw new Error( + `Unsupported document source type: ${(source as { type: string }).type}` + ); + } +} diff --git a/js/plugins/anthropic/src/runner/converters/stable.ts b/js/plugins/anthropic/src/runner/converters/stable.ts index 5de9221a12..996ffa208d 100644 --- a/js/plugins/anthropic/src/runner/converters/stable.ts +++ b/js/plugins/anthropic/src/runner/converters/stable.ts @@ -21,6 +21,7 @@ import type { DocumentBlockParam } from '@anthropic-ai/sdk/resources/messages'; import type { Part } from 'genkit'; import type { AnthropicDocumentOptions } from '../../types.js'; +import { convertDocumentSource, createDocumentBlock } from './shared.js'; /** * Converts a server_tool_use block to a Genkit Part. @@ -45,81 +46,16 @@ export function serverToolUseBlockToPart(block: { /** * Converts AnthropicDocumentOptions to Anthropic's stable API document block format. + * Note: The stable API does not support file-based sources (Files API). */ export function toDocumentBlock( options: AnthropicDocumentOptions ): DocumentBlockParam { - const block: DocumentBlockParam = { - type: 'document', - source: toDocumentSource(options.source), - }; - - if (options.title) { - block.title = options.title; - } - if (options.context) { - block.context = options.context; - } - if (options.citations) { - block.citations = options.citations; - } - - return block; -} - -/** - * Converts document source options to Anthropic's stable API source format. - * Note: The stable API does not support file-based sources (Files API). - */ -function toDocumentSource( - source: AnthropicDocumentOptions['source'] -): DocumentBlockParam['source'] { - switch (source.type) { - case 'text': - return { - type: 'text', - media_type: (source.mediaType ?? 'text/plain') as 'text/plain', - data: source.data, - }; - case 'base64': - return { - type: 'base64', - media_type: source.mediaType as 'application/pdf', - data: source.data, - }; - case 'file': + return createDocumentBlock(options, (source) => + convertDocumentSource(source, () => { throw new Error( 'File-based document sources require the beta API. Set apiVersion: "beta" in your plugin config or request config.' ); - case 'content': - return { - type: 'content', - content: source.content.map((item) => { - if (item.type === 'text') { - return item; - } - return { - type: 'image' as const, - source: { - type: 'base64' as const, - media_type: item.source.mediaType as - | 'image/jpeg' - | 'image/png' - | 'image/gif' - | 'image/webp', - data: item.source.data, - }, - }; - }), - }; - case 'url': - return { - type: 'url', - url: source.url, - }; - default: - throw new Error( - `Unsupported document source type: ${(source as { type: string }).type}` - ); - } + }) + ); } diff --git a/js/plugins/anthropic/tests/live_test.ts b/js/plugins/anthropic/tests/live_test.ts index 4d699908bb..280e00c8b6 100644 --- a/js/plugins/anthropic/tests/live_test.ts +++ b/js/plugins/anthropic/tests/live_test.ts @@ -295,4 +295,213 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); assert.ok(citation.citedText, 'Citation should have cited text'); }); + + it('should return citations from a custom content document with content_block_location', async () => { + const ai = genkit({ + plugins: [anthropic({ apiKey: API_KEY, apiVersion: 'beta' })], + }); + + const result = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'content', + content: [ + { type: 'text', text: 'Fact 1: Dogs are mammals.' }, + { type: 'text', text: 'Fact 2: Cats are also mammals.' }, + { type: 'text', text: 'Fact 3: Birds have feathers.' }, + { type: 'text', text: 'Fact 4: Fish live in water.' }, + ], + }, + title: 'Animal Facts', + citations: { enabled: true }, + }), + { + text: 'What do dogs and cats have in common? Cite your source with block references.', + }, + ], + }, + ], + }); + + assert.ok(result.text, 'Should have response text'); + assert.ok( + result.text.toLowerCase().includes('mammal'), + 'Response should mention mammals' + ); + + // Extract citations from response parts + const citations = result.message?.content + .filter((part) => part.metadata?.citations) + .flatMap( + (part) => part.metadata?.citations as AnthropicCitation[] | undefined + ) + .filter((c): c is AnthropicCitation => c !== undefined); + + assert.ok( + citations && citations.length > 0, + 'Should have at least one citation' + ); + + // Verify at least one citation is content_block_location type + const contentBlockCitations = citations.filter( + (c) => c.type === 'content_block_location' + ); + assert.ok( + contentBlockCitations.length > 0, + 'Should have at least one content_block_location citation' + ); + + // Verify content_block_location citation structure + const contentBlockCitation = contentBlockCitations[0]; + assert.strictEqual( + contentBlockCitation.type, + 'content_block_location', + 'Should be a content_block_location citation' + ); + assert.ok( + contentBlockCitation.citedText, + 'Citation should have cited text' + ); + assert.strictEqual( + contentBlockCitation.documentIndex, + 0, + 'Should reference first document' + ); + assert.ok( + typeof contentBlockCitation.startBlockIndex === 'number', + 'Citation should have startBlockIndex' + ); + assert.ok( + typeof contentBlockCitation.endBlockIndex === 'number', + 'Citation should have endBlockIndex' + ); + assert.ok( + contentBlockCitation.startBlockIndex >= 0, + 'startBlockIndex should be non-negative' + ); + assert.ok( + contentBlockCitation.endBlockIndex >= + contentBlockCitation.startBlockIndex, + 'endBlockIndex should be >= startBlockIndex' + ); + }); + + it('should return citations from multiple documents with correct document indexing', async () => { + const ai = genkit({ + plugins: [anthropic({ apiKey: API_KEY, apiVersion: 'beta' })], + }); + + const result = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'text', + data: 'The capital of France is Paris. The capital of Germany is Berlin.', + }, + title: 'European Capitals', + citations: { enabled: true }, + }), + anthropicDocument({ + source: { + type: 'text', + data: 'The capital of Japan is Tokyo. The capital of China is Beijing.', + }, + title: 'Asian Capitals', + citations: { enabled: true }, + }), + { + text: 'What are the capitals of France and Japan? Cite your sources for each.', + }, + ], + }, + ], + }); + + assert.ok(result.text, 'Should have response text'); + assert.ok( + result.text.toLowerCase().includes('paris'), + 'Response should mention Paris' + ); + assert.ok( + result.text.toLowerCase().includes('tokyo'), + 'Response should mention Tokyo' + ); + + // Extract citations from response parts + const citations = result.message?.content + .filter((part) => part.metadata?.citations) + .flatMap( + (part) => part.metadata?.citations as AnthropicCitation[] | undefined + ) + .filter((c): c is AnthropicCitation => c !== undefined); + + assert.ok( + citations && citations.length > 0, + 'Should have at least one citation' + ); + + // Verify citations reference different documents + const documentIndices = new Set(citations.map((c) => c.documentIndex)); + assert.ok( + documentIndices.size >= 1, + 'Should have citations from at least one document' + ); + assert.ok( + documentIndices.has(0) || documentIndices.has(1), + 'Should have citations from document 0 or 1' + ); + + // Verify citation structure for each document index + for (const citation of citations) { + assert.ok( + citation.documentIndex === 0 || citation.documentIndex === 1, + `Citation documentIndex should be 0 or 1, got ${citation.documentIndex}` + ); + assert.ok(citation.citedText, 'Citation should have cited text'); + assert.strictEqual( + citation.type, + 'char_location', + 'Text document citations should be char_location type' + ); + + // Verify char_location specific fields + if (citation.type === 'char_location') { + assert.ok( + typeof citation.startCharIndex === 'number', + 'Citation should have startCharIndex' + ); + assert.ok( + typeof citation.endCharIndex === 'number', + 'Citation should have endCharIndex' + ); + assert.ok( + citation.endCharIndex >= citation.startCharIndex, + 'endCharIndex should be >= startCharIndex' + ); + } + } + + // If we have citations from both documents, verify they reference different content + if (documentIndices.size === 2) { + const doc0Citations = citations.filter((c) => c.documentIndex === 0); + const doc1Citations = citations.filter((c) => c.documentIndex === 1); + assert.ok( + doc0Citations.length > 0, + 'Should have citations from document 0' + ); + assert.ok( + doc1Citations.length > 0, + 'Should have citations from document 1' + ); + } + }); }); From d9508a08a699997698c32bc634d74af2a1d5e204 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 19 Jan 2026 14:03:02 +0000 Subject: [PATCH 5/9] docs(js/plugins/anthropic): add citations docs to the README --- js/plugins/anthropic/README.md | 146 +++++++++++++++++++++++++-------- 1 file changed, 111 insertions(+), 35 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 99bb31e688..ef3d153c1f 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -4,11 +4,16 @@

Anthropic AI plugin for Google Firebase Genkit

-`@genkit-ai/anthropic` is the official Anthropic plugin for [Firebase Genkit](https://github.com/firebase/genkit). It supersedes the earlier community package `genkitx-anthropic` and is now maintained by Google. +`@genkit-ai/anthropic` is the official Anthropic plugin for +[Firebase Genkit](https://github.com/firebase/genkit). It supersedes the earlier +community package `genkitx-anthropic` and is now maintained by Google. ## Supported models -The plugin supports the most recent Anthropic models: **Claude Haiku 4.5**, **Claude Sonnet 4.5**, and **Claude Opus 4.5**. Additionally, the plugin supports all of the [non-retired older models](https://platform.claude.com/docs/en/about-claude/model-deprecations#model-status). +The plugin supports the most recent Anthropic models: **Claude Haiku 4.5**, +**Claude Sonnet 4.5**, and **Claude Opus 4.5**. Additionally, the plugin +supports all of the +[non-retired older models](https://platform.claude.com/docs/en/about-claude/model-deprecations#model-status). ## Installation @@ -23,13 +28,13 @@ Install the plugin in your project with your favorite package manager: ### Initialize ```typescript -import { genkit } from 'genkit'; -import { anthropic } from '@genkit-ai/anthropic'; +import { genkit } from "genkit"; +import { anthropic } from "@genkit-ai/anthropic"; const ai = genkit({ plugins: [anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })], // specify a default model for generate here if you wish: - model: anthropic.model('claude-sonnet-4-5'), + model: anthropic.model("claude-sonnet-4-5"), }); ``` @@ -39,8 +44,8 @@ The simplest way to generate text is by using the `generate` method: ```typescript const response = await ai.generate({ - model: anthropic.model('claude-haiku-4-5'), - prompt: 'Tell me a joke.', + model: anthropic.model("claude-haiku-4-5"), + prompt: "Tell me a joke.", }); console.log(response.text); @@ -53,7 +58,7 @@ console.log(response.text); const response = await ai.generate({ prompt: [ - { text: 'What animal is in the photo?' }, + { text: "What animal is in the photo?" }, { media: { url: imageUrl } }, ], }); @@ -62,11 +67,12 @@ console.log(response.text); ### Extended thinking -Claude 4.5 models can expose their internal reasoning. Enable it per-request with the Anthropic thinking config and read the reasoning from the response: +Claude 4.5 models can expose their internal reasoning. Enable it per-request +with the Anthropic thinking config and read the reasoning from the response: ```typescript const response = await ai.generate({ - prompt: 'Walk me through your reasoning for Fermat’s little theorem.', + prompt: "Walk me through your reasoning for Fermat’s little theorem.", config: { thinking: { enabled: true, @@ -75,15 +81,76 @@ const response = await ai.generate({ }, }); -console.log(response.text); // Final assistant answer -console.log(response.reasoning); // Summarized thinking steps +console.log(response.text); // Final assistant answer +console.log(response.reasoning); // Summarized thinking steps ``` -When thinking is enabled, request bodies sent through the plugin include the `thinking` payload (`{ type: 'enabled', budget_tokens: … }`) that Anthropic's API expects, and streamed responses deliver `reasoning` parts as they arrive so you can render the chain-of-thought incrementally. +When thinking is enabled, request bodies sent through the plugin include the +`thinking` payload (`{ type: 'enabled', budget_tokens: … }`) that Anthropic's +API expects, and streamed responses deliver `reasoning` parts as they arrive so +you can render the chain-of-thought incrementally. + +### Document Citations + +Claude can cite specific parts of documents you provide, making it easy to trace +where information in the response came from. Use the `anthropicDocument()` +helper to create citable documents. For more details, see the +[Anthropic Citations documentation](https://platform.claude.com/docs/en/build-with-claude/citations). + +```typescript +import { anthropic, anthropicDocument } from "@genkit-ai/anthropic"; + +const response = await ai.generate({ + model: anthropic.model("claude-sonnet-4-5"), + messages: [ + { + role: "user", + content: [ + anthropicDocument({ + source: { + type: "text", + data: "The grass is green. The sky is blue. Water is wet.", + }, + title: "Basic Facts", + citations: { enabled: true }, + }), + { text: "What color is the grass? Cite your source." }, + ], + }, + ], +}); + +// Access citations from response parts +const citations = response.message?.content + .filter((part) => part.metadata?.citations) + .flatMap((part) => part.metadata?.citations); + +console.log("Citations:", citations); +``` + +**Important:** Citations must be enabled on all documents in a request, or on +none of them. You cannot mix documents with citations enabled and disabled in +the same request. + +Supported document source types: + +- `text` - Plain text documents (returns `char_location` citations) +- `base64` - Base64-encoded PDFs (returns `page_location` citations) +- `url` - PDFs accessible via URL (returns `page_location` citations) +- `content` - Custom content blocks with text/images (returns + `content_block_location` citations) +- `file` - File references from Anthropic's Files API, beta API only (returns + `page_location` citations) + +Citations are returned in the response parts' metadata and include information +about the document index, cited text, and location (character indices, page +numbers, or block indices depending on the source type). ### Beta API Limitations -The beta API surface provides access to experimental features, but some server-managed tool blocks are not yet supported by this plugin. The following beta API features will cause an error if encountered: +The beta API surface provides access to experimental features, but some +server-managed tool blocks are not yet supported by this plugin. The following +beta API features will cause an error if encountered: - `web_fetch_tool_result` - `code_execution_tool_result` @@ -93,18 +160,19 @@ The beta API surface provides access to experimental features, but some server-m - `mcp_tool_use` - `container_upload` -Note that `server_tool_use` and `web_search_tool_result` ARE supported and work with both stable and beta APIs. +Note that `server_tool_use` and `web_search_tool_result` ARE supported and work +with both stable and beta APIs. ### Within a flow ```typescript -import { z } from 'genkit'; +import { z } from "genkit"; // ...initialize Genkit instance (as shown above)... export const jokeFlow = ai.defineFlow( { - name: 'jokeFlow', + name: "jokeFlow", inputSchema: z.string(), outputSchema: z.string(), }, @@ -113,26 +181,27 @@ export const jokeFlow = ai.defineFlow( prompt: `tell me a joke about ${subject}`, }); return llmResponse.text; - } + }, ); ``` ### Direct model usage (without Genkit instance) -The plugin supports Genkit Plugin API v2, which allows you to use models directly without initializing the full Genkit framework: +The plugin supports Genkit Plugin API v2, which allows you to use models +directly without initializing the full Genkit framework: ```typescript -import { anthropic } from '@genkit-ai/anthropic'; +import { anthropic } from "@genkit-ai/anthropic"; // Create a model reference directly -const claude = anthropic.model('claude-sonnet-4-5'); +const claude = anthropic.model("claude-sonnet-4-5"); // Use the model directly const response = await claude({ messages: [ { - role: 'user', - content: [{ text: 'Tell me a joke.' }], + role: "user", + content: [{ text: "Tell me a joke." }], }, ], }); @@ -143,19 +212,19 @@ console.log(response); You can also create model references using the plugin's `model()` method: ```typescript -import { anthropic } from '@genkit-ai/anthropic'; +import { anthropic } from "@genkit-ai/anthropic"; // Create model references -const claudeHaiku45 = anthropic.model('claude-haiku-4-5'); -const claudeSonnet45 = anthropic.model('claude-sonnet-4-5'); -const claudeOpus45 = anthropic.model('claude-opus-4-5'); +const claudeHaiku45 = anthropic.model("claude-haiku-4-5"); +const claudeSonnet45 = anthropic.model("claude-sonnet-4-5"); +const claudeOpus45 = anthropic.model("claude-opus-4-5"); // Use the model reference directly const response = await claudeSonnet45({ messages: [ { - role: 'user', - content: [{ text: 'Hello!' }], + role: "user", + content: [{ text: "Hello!" }], }, ], }); @@ -169,22 +238,29 @@ This approach is useful for: ## Acknowledgements -This plugin builds on the community work published as [`genkitx-anthropic`](https://github.com/BloomLabsInc/genkit-plugins/blob/main/plugins/anthropic/README.md) by Bloom Labs Inc. Their Apache 2.0–licensed implementation provided the foundation for this maintained package. +This plugin builds on the community work published as +[`genkitx-anthropic`](https://github.com/BloomLabsInc/genkit-plugins/blob/main/plugins/anthropic/README.md) +by Bloom Labs Inc. Their Apache 2.0–licensed implementation provided the +foundation for this maintained package. ## Contributing -Want to contribute to the project? That's awesome! Head over to our [Contribution Guidelines](CONTRIBUTING.md). +Want to contribute to the project? That's awesome! Head over to our +[Contribution Guidelines](CONTRIBUTING.md). ## Need support? > [!NOTE] -> This repository depends on Google's Firebase Genkit. For issues and questions related to Genkit, please refer to instructions available in [Genkit's repository](https://github.com/firebase/genkit). - +> This repository depends on Google's Firebase Genkit. For issues and questions +> related to Genkit, please refer to instructions available in +> [Genkit's repository](https://github.com/firebase/genkit). ## Credits -This plugin is maintained by Google with acknowledgement to the community contributions from [Bloom Labs Inc](https://github.com/BloomLabsInc). +This plugin is maintained by Google with acknowledgement to the community +contributions from [Bloom Labs Inc](https://github.com/BloomLabsInc). ## License -This project is licensed under the [Apache 2.0 License](https://github.com/BloomLabsInc/genkit-plugins/blob/main/LICENSE). +This project is licensed under the +[Apache 2.0 License](https://github.com/BloomLabsInc/genkit-plugins/blob/main/LICENSE). From 7032926c2c111c313238df365b47758ff1df7e4c Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 19 Jan 2026 14:13:41 +0000 Subject: [PATCH 6/9] refactor(js/plugins/anthropic): clean up citations converter logic --- .../src/runner/converters/citations.ts | 144 ++++++++++++++++++ .../anthropic/src/runner/converters/shared.ts | 90 +++-------- 2 files changed, 164 insertions(+), 70 deletions(-) create mode 100644 js/plugins/anthropic/src/runner/converters/citations.ts diff --git a/js/plugins/anthropic/src/runner/converters/citations.ts b/js/plugins/anthropic/src/runner/converters/citations.ts new file mode 100644 index 0000000000..a6d5ab9a94 --- /dev/null +++ b/js/plugins/anthropic/src/runner/converters/citations.ts @@ -0,0 +1,144 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Citation conversion utilities for Anthropic API responses. + * Handles validation and conversion of citations from Anthropic's format to Genkit format. + */ + +import { z } from 'genkit'; +import type { AnthropicCitation } from '../../types.js'; + +/** Structural type for Anthropic citations (works with both stable and beta APIs). */ +export interface AnthropicCitationInput { + type: string; + cited_text: string; + // document_index is optional since web search citations don't have it + document_index?: number; + document_title?: string | null; + file_id?: string | null; + start_char_index?: number; + end_char_index?: number; + start_page_number?: number; + end_page_number?: number; + start_block_index?: number; + end_block_index?: number; +} + +// --- Citation validation schemas --- + +/** + * Base citation schema with common fields shared across all citation types. + */ +const baseCitationSchema = z.object({ + cited_text: z.string(), + document_index: z.number(), + document_title: z.string().nullable().optional(), + file_id: z.string().nullable().optional(), +}); + +/** + * Schema for character location citations (plain text documents). + */ +const charLocationCitationSchema = baseCitationSchema.extend({ + type: z.literal('char_location'), + start_char_index: z.number(), + end_char_index: z.number(), +}); + +/** + * Schema for page location citations (PDF documents). + */ +const pageLocationCitationSchema = baseCitationSchema.extend({ + type: z.literal('page_location'), + start_page_number: z.number(), + end_page_number: z.number(), +}); + +/** + * Schema for content block location citations (custom content documents). + */ +const contentBlockLocationCitationSchema = baseCitationSchema.extend({ + type: z.literal('content_block_location'), + start_block_index: z.number(), + end_block_index: z.number(), +}); + +/** + * Discriminated union schema for all supported citation types. + */ +const citationSchema = z.discriminatedUnion('type', [ + charLocationCitationSchema, + pageLocationCitationSchema, + contentBlockLocationCitationSchema, +]); + +/** + * Converts Anthropic's citation format (snake_case) to genkit format (camelCase). + * Only handles document-based citations (char_location, page_location, content_block_location). + * Skips web search and other citation types that don't reference documents. + */ +export function fromAnthropicCitation( + citation: AnthropicCitationInput +): AnthropicCitation | undefined { + // Skip citations without document_index (e.g., web search results) + if (citation.document_index === undefined) { + return undefined; + } + + // Validate and parse citation with Zod + const result = citationSchema.safeParse(citation); + if (!result.success) { + // Invalid citation structure, skip it gracefully + return undefined; + } + + const parsed = result.data; + + // Convert validated citation to Genkit format + switch (parsed.type) { + case 'char_location': + return { + type: 'char_location', + citedText: parsed.cited_text, + documentIndex: parsed.document_index, + documentTitle: parsed.document_title ?? undefined, + fileId: parsed.file_id ?? undefined, + startCharIndex: parsed.start_char_index, + endCharIndex: parsed.end_char_index, + }; + case 'page_location': + return { + type: 'page_location', + citedText: parsed.cited_text, + documentIndex: parsed.document_index, + documentTitle: parsed.document_title ?? undefined, + fileId: parsed.file_id ?? undefined, + startPageNumber: parsed.start_page_number, + endPageNumber: parsed.end_page_number, + }; + case 'content_block_location': + return { + type: 'content_block_location', + citedText: parsed.cited_text, + documentIndex: parsed.document_index, + documentTitle: parsed.document_title ?? undefined, + fileId: parsed.file_id ?? undefined, + startBlockIndex: parsed.start_block_index, + endBlockIndex: parsed.end_block_index, + }; + } +} diff --git a/js/plugins/anthropic/src/runner/converters/shared.ts b/js/plugins/anthropic/src/runner/converters/shared.ts index 8b19007f44..c40cc82d4d 100644 --- a/js/plugins/anthropic/src/runner/converters/shared.ts +++ b/js/plugins/anthropic/src/runner/converters/shared.ts @@ -24,72 +24,17 @@ import type { AnthropicCitation, AnthropicDocumentOptions, } from '../../types.js'; +import { MEDIA_TYPES, MediaTypeSchema } from '../../types.js'; +import { + fromAnthropicCitation, + type AnthropicCitationInput, +} from './citations.js'; -/** Structural type for Anthropic citations (works with both stable and beta APIs). */ -interface AnthropicCitationInput { - type: string; - cited_text: string; - // document_index is optional since web search citations don't have it - document_index?: number; - document_title?: string | null; - file_id?: string | null; - start_char_index?: number; - end_char_index?: number; - start_page_number?: number; - end_page_number?: number; - start_block_index?: number; - end_block_index?: number; -} - -/** - * Converts Anthropic's citation format (snake_case) to genkit format (camelCase). - * Only handles document-based citations (char_location, page_location, content_block_location). - * Skips web search and other citation types that don't reference documents. - */ -export function fromAnthropicCitation( - citation: AnthropicCitationInput -): AnthropicCitation | undefined { - // Skip citations without document_index (e.g., web search results) - if (citation.document_index === undefined) { - return undefined; - } - - switch (citation.type) { - case 'char_location': - return { - type: 'char_location', - citedText: citation.cited_text, - documentIndex: citation.document_index, - documentTitle: citation.document_title ?? undefined, - fileId: citation.file_id ?? undefined, - startCharIndex: citation.start_char_index!, - endCharIndex: citation.end_char_index!, - }; - case 'page_location': - return { - type: 'page_location', - citedText: citation.cited_text, - documentIndex: citation.document_index, - documentTitle: citation.document_title ?? undefined, - fileId: citation.file_id ?? undefined, - startPageNumber: citation.start_page_number!, - endPageNumber: citation.end_page_number!, - }; - case 'content_block_location': - return { - type: 'content_block_location', - citedText: citation.cited_text, - documentIndex: citation.document_index, - documentTitle: citation.document_title ?? undefined, - fileId: citation.file_id ?? undefined, - startBlockIndex: citation.start_block_index!, - endBlockIndex: citation.end_block_index!, - }; - default: - // Skip web search and other citation types - they're not from documents - return undefined; - } -} +// Re-export citation utilities for backward compatibility +export { + fromAnthropicCitation, + type AnthropicCitationInput, +} from './citations.js'; /** * Converts a text block to a Genkit Part, including citations if present. @@ -280,15 +225,20 @@ export function convertDocumentSource( if (item.type === 'text') { return item; } + // Validate media type with Zod + const mediaTypeResult = MediaTypeSchema.safeParse( + item.source.mediaType + ); + if (!mediaTypeResult.success) { + throw new Error( + `Unsupported image media type for Anthropic document content: ${item.source.mediaType}. Supported types: ${Object.values(MEDIA_TYPES).join(', ')}` + ); + } return { type: 'image' as const, source: { type: 'base64' as const, - media_type: item.source.mediaType as - | 'image/jpeg' - | 'image/png' - | 'image/gif' - | 'image/webp', + media_type: mediaTypeResult.data, data: item.source.data, }, }; From e3b78127a592fe28da8245a4971fbd6dac823f85 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 19 Jan 2026 14:16:23 +0000 Subject: [PATCH 7/9] test(js/plugins/anthropic): add edge case citation live tests --- js/plugins/anthropic/tests/live_test.ts | 105 ++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/js/plugins/anthropic/tests/live_test.ts b/js/plugins/anthropic/tests/live_test.ts index 280e00c8b6..4543e658c4 100644 --- a/js/plugins/anthropic/tests/live_test.ts +++ b/js/plugins/anthropic/tests/live_test.ts @@ -504,4 +504,109 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); } }); + + it('should throw descriptive error for invalid image media types in document content', async () => { + const ai = genkit({ + plugins: [anthropic({ apiKey: API_KEY, apiVersion: 'beta' })], + }); + + // Test that invalid media type throws descriptive error + await assert.rejects( + async () => { + await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'content', + content: [ + { + type: 'image', + source: { + type: 'base64', + mediaType: 'image/bmp', // Invalid - not supported + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + }, + }, + ], + }, + citations: { enabled: true }, + }), + { text: 'What is in this image?' }, + ], + }, + ], + }); + }, + (error: Error) => { + assert.ok( + error.message.includes('Unsupported image media type'), + 'Error should mention unsupported media type' + ); + assert.ok( + error.message.includes('image/bmp'), + 'Error should include the invalid media type' + ); + assert.ok( + error.message.includes('image/jpeg') || + error.message.includes('image/png') || + error.message.includes('image/gif') || + error.message.includes('image/webp'), + 'Error should include supported types' + ); + return true; + } + ); + }); + + it('should handle valid image media types in document content gracefully', async () => { + const ai = genkit({ + plugins: [anthropic({ apiKey: API_KEY, apiVersion: 'beta' })], + }); + + // Test that valid media types work correctly + // Using a minimal valid PNG (1x1 transparent pixel) + const validPngBase64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + + const result = await ai.generate({ + model: anthropic.model('claude-sonnet-4-5'), + messages: [ + { + role: 'user', + content: [ + anthropicDocument({ + source: { + type: 'content', + content: [ + { type: 'text', text: 'This document contains an image.' }, + { + type: 'image', + source: { + type: 'base64', + mediaType: 'image/png', // Valid media type + data: validPngBase64, + }, + }, + ], + }, + title: 'Document with Image', + citations: { enabled: true }, + }), + { text: 'What does the document say?' }, + ], + }, + ], + }); + + // Should not throw and should return a response + assert.ok(result.text, 'Should have response text'); + assert.ok( + result.text.length > 0, + 'Response should not be empty' + ); + }); }); From 4153197f98c9b92a5df1c3ed42dfbe92cd53c853 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 19 Jan 2026 14:17:49 +0000 Subject: [PATCH 8/9] chore(js/plugins/anthropic): format --- js/plugins/anthropic/tests/live_test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/js/plugins/anthropic/tests/live_test.ts b/js/plugins/anthropic/tests/live_test.ts index 4543e658c4..86cdd179b0 100644 --- a/js/plugins/anthropic/tests/live_test.ts +++ b/js/plugins/anthropic/tests/live_test.ts @@ -604,9 +604,6 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { // Should not throw and should return a response assert.ok(result.text, 'Should have response text'); - assert.ok( - result.text.length > 0, - 'Response should not be empty' - ); + assert.ok(result.text.length > 0, 'Response should not be empty'); }); }); From d96cde0ae05432da82f138f8377cf7c4fe671176 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 19 Jan 2026 15:27:16 +0000 Subject: [PATCH 9/9] refactor(js/plugins/anthropic): clean up citations extraction in tests and in converters --- js/plugins/anthropic/README.md | 6 +-- js/plugins/anthropic/tests/live_test.ts | 50 +++++++++------------ js/testapps/anthropic/src/beta/citations.ts | 7 +-- 3 files changed, 27 insertions(+), 36 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index ef3d153c1f..2f664d54ba 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -121,9 +121,9 @@ const response = await ai.generate({ }); // Access citations from response parts -const citations = response.message?.content - .filter((part) => part.metadata?.citations) - .flatMap((part) => part.metadata?.citations); +const citations = response.message?.content?.flatMap( + (part) => part.metadata?.citations || [], +) ?? []; console.log("Citations:", citations); ``` diff --git a/js/plugins/anthropic/tests/live_test.ts b/js/plugins/anthropic/tests/live_test.ts index 86cdd179b0..631ffc81ed 100644 --- a/js/plugins/anthropic/tests/live_test.ts +++ b/js/plugins/anthropic/tests/live_test.ts @@ -163,12 +163,10 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); // Extract citations from response parts - const citations = result.message?.content - .filter((part) => part.metadata?.citations) - .flatMap( - (part) => part.metadata?.citations as AnthropicCitation[] | undefined - ) - .filter((c): c is AnthropicCitation => c !== undefined); + const citations = + result.message?.content?.flatMap( + (part) => (part.metadata?.citations as AnthropicCitation[]) || [] + ) ?? []; assert.ok( citations && citations.length > 0, @@ -229,12 +227,10 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); // Extract citations from final response - const citations = result.message?.content - .filter((part) => part.metadata?.citations) - .flatMap( - (part) => part.metadata?.citations as AnthropicCitation[] | undefined - ) - .filter((c): c is AnthropicCitation => c !== undefined); + const citations = + result.message?.content?.flatMap( + (part) => (part.metadata?.citations as AnthropicCitation[]) || [] + ) ?? []; assert.ok( citations && citations.length > 0, @@ -274,12 +270,10 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); // Extract citations from response parts - const citations = result.message?.content - .filter((part) => part.metadata?.citations) - .flatMap( - (part) => part.metadata?.citations as AnthropicCitation[] | undefined - ) - .filter((c): c is AnthropicCitation => c !== undefined); + const citations = + result.message?.content?.flatMap( + (part) => (part.metadata?.citations as AnthropicCitation[]) || [] + ) ?? []; assert.ok( citations && citations.length > 0, @@ -335,12 +329,10 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); // Extract citations from response parts - const citations = result.message?.content - .filter((part) => part.metadata?.citations) - .flatMap( - (part) => part.metadata?.citations as AnthropicCitation[] | undefined - ) - .filter((c): c is AnthropicCitation => c !== undefined); + const citations = + result.message?.content?.flatMap( + (part) => (part.metadata?.citations as AnthropicCitation[]) || [] + ) ?? []; assert.ok( citations && citations.length > 0, @@ -437,12 +429,10 @@ describe('Live Anthropic API Tests', { skip: !API_KEY }, () => { ); // Extract citations from response parts - const citations = result.message?.content - .filter((part) => part.metadata?.citations) - .flatMap( - (part) => part.metadata?.citations as AnthropicCitation[] | undefined - ) - .filter((c): c is AnthropicCitation => c !== undefined); + const citations = + result.message?.content?.flatMap( + (part) => (part.metadata?.citations as AnthropicCitation[]) || [] + ) ?? []; assert.ok( citations && citations.length > 0, diff --git a/js/testapps/anthropic/src/beta/citations.ts b/js/testapps/anthropic/src/beta/citations.ts index a19c0b0256..dea9938e5a 100644 --- a/js/testapps/anthropic/src/beta/citations.ts +++ b/js/testapps/anthropic/src/beta/citations.ts @@ -61,9 +61,10 @@ ai.defineFlow('citations-text', async () => { ); // Extract citations from the response - const citations = response.message?.content - .filter((part) => part.metadata?.citations) - .flatMap((part) => part.metadata?.citations); + const citations = + response.message?.content?.flatMap( + (part) => part.metadata?.citations || [] + ) ?? []; console.log('Citations:', JSON.stringify(citations, null, 2));