diff --git a/js/plugins/anthropic/src/parts/input_json_part.ts b/js/plugins/anthropic/src/parts/input_json_part.ts new file mode 100644 index 0000000000..e88fbeb2ab --- /dev/null +++ b/js/plugins/anthropic/src/parts/input_json_part.ts @@ -0,0 +1,47 @@ +/** + * 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 { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID_DELTA = 'input_json_delta'; + +export const InputJsonPart: SupportedPart = { + abilities: [ + { + id: ID_DELTA, + when: SupportedPartWhen.StreamDelta, + what: SupportedPartWhat.ContentBlock, + func: (delta) => { + if (delta.type !== ID_DELTA) { + throwErrorWrongTypeForAbility( + ID_DELTA, + SupportedPartWhen.StreamDelta, + SupportedPartWhat.ContentBlock + ); + } + + throw new Error( + `Anthropic streaming tool input (${ID_DELTA}) is not yet supported. Please disable streaming or upgrade this plugin.` + ); + }, + }, + ], +}; diff --git a/js/plugins/anthropic/src/parts/mcp_tool_use.ts b/js/plugins/anthropic/src/parts/mcp_tool_use.ts new file mode 100644 index 0000000000..3dc66673d8 --- /dev/null +++ b/js/plugins/anthropic/src/parts/mcp_tool_use.ts @@ -0,0 +1,46 @@ +/** + * 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 { unsupportedBetaServerToolError } from '../utils'; +import { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID = 'mcp_tool_use'; + +export const McpToolUsePart: SupportedPart = { + abilities: [ + { + id: ID, + when: SupportedPartWhen.NonStream, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + } + + throw new Error(unsupportedBetaServerToolError(contentBlock.type)); + }, + }, + ], +}; diff --git a/js/plugins/anthropic/src/parts/part.ts b/js/plugins/anthropic/src/parts/part.ts new file mode 100644 index 0000000000..bfa2cd4d34 --- /dev/null +++ b/js/plugins/anthropic/src/parts/part.ts @@ -0,0 +1,81 @@ +/** + * 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 { + ContentBlock, + RawContentBlockDelta, +} from '@anthropic-ai/sdk/resources'; +import { + BetaContentBlock, + BetaRawContentBlockDelta, + BetaRawMessageStreamEvent, +} from '@anthropic-ai/sdk/resources/beta.js'; +import { MessageStreamEvent } from '@anthropic-ai/sdk/resources/messages.js'; + +export interface SupportedPart { + abilities: Ability[]; +} + +export interface Ability { + id: string; + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen]; + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat]; + func: ( + chunk: + | MessageStreamEvent + | BetaRawMessageStreamEvent + | ContentBlock + | BetaContentBlock + | RawContentBlockDelta + | BetaRawContentBlockDelta + ) => any; +} + +export const SupportedPartWhen = { + StreamStart: 'stream_start' as const, + StreamDelta: 'stream_delta' as const, + StreamEnd: 'stream_end' as const, + NonStream: 'non_stream' as const, +}; + +export const SupportedPartWhat = { + ContentBlock: 'content_block' as const, +}; + +export function throwErrorWrongTypeForAbility( + partId: string, + chunk: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen], + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat] +): never { + switch (chunk) { + case SupportedPartWhen.StreamStart: + throw new Error( + `Part '${partId}' is not supported for stream start in ${what}` + ); + case SupportedPartWhen.StreamDelta: + throw new Error( + `Part '${partId}' is not supported for stream delta in ${what}` + ); + case SupportedPartWhen.StreamEnd: + throw new Error( + `Part '${partId}' is not supported for stream end in ${what}` + ); + case SupportedPartWhen.NonStream: + throw new Error( + `Part '${partId}' is not supported for non stream in ${what}` + ); + } +} diff --git a/js/plugins/anthropic/src/parts/redacted_thinking_part.ts b/js/plugins/anthropic/src/parts/redacted_thinking_part.ts new file mode 100644 index 0000000000..b80c47421a --- /dev/null +++ b/js/plugins/anthropic/src/parts/redacted_thinking_part.ts @@ -0,0 +1,62 @@ +/** + * 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 { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID = 'redacted_thinking'; + +export const RedactedThinkingPart: SupportedPart = { + abilities: [ + { + id: ID, + when: SupportedPartWhen.NonStream, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + } + + return { custom: { redactedThinking: contentBlock.data } }; + }, + }, + + { + id: ID, + when: SupportedPartWhen.StreamStart, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.StreamStart, + SupportedPartWhat.ContentBlock + ); + } + + return { custom: { redactedThinking: contentBlock.data } }; + }, + }, + ], +}; diff --git a/js/plugins/anthropic/src/parts/server_tool_use_part.ts b/js/plugins/anthropic/src/parts/server_tool_use_part.ts new file mode 100644 index 0000000000..e2baeac61d --- /dev/null +++ b/js/plugins/anthropic/src/parts/server_tool_use_part.ts @@ -0,0 +1,85 @@ +/** + * 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 { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID = 'server_tool_use'; + +export const ServerToolUsePart: SupportedPart = { + abilities: [ + { + id: ID, + when: SupportedPartWhen.NonStream, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + } + + const baseName = contentBlock.name ?? 'unknown_tool'; + const serverToolName = + 'server_name' in contentBlock && contentBlock.server_name + ? `${contentBlock.server_name}/${baseName}` + : baseName; + + return { + text: `[Anthropic server tool ${serverToolName}] input: ${JSON.stringify(contentBlock.input)}`, + custom: { + anthropicServerToolUse: { + id: contentBlock.id, + name: serverToolName, + input: contentBlock.input, + }, + }, + }; + }, + }, + + { + id: ID, + when: SupportedPartWhen.StreamStart, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.StreamStart, + SupportedPartWhat.ContentBlock + ); + } + return { + text: `[Anthropic server tool ${contentBlock.name}] input: ${JSON.stringify(contentBlock.input)}`, + custom: { + anthropicServerToolUse: { + id: contentBlock.id, + name: contentBlock.name ?? 'unknown_tool', + input: contentBlock.input, + }, + }, + }; + }, + }, + ], +}; diff --git a/js/plugins/anthropic/src/parts/text_part.ts b/js/plugins/anthropic/src/parts/text_part.ts new file mode 100644 index 0000000000..17b8903b3c --- /dev/null +++ b/js/plugins/anthropic/src/parts/text_part.ts @@ -0,0 +1,80 @@ +/** + * 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 { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID = 'text'; +const ID_DELTA = 'text_delta'; + +export const TextPart: SupportedPart = { + abilities: [ + { + id: ID, + when: SupportedPartWhen.NonStream, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + } + + return { text: contentBlock.text }; + }, + }, + + { + id: ID_DELTA, + when: SupportedPartWhen.StreamDelta, + what: SupportedPartWhat.ContentBlock, + func: (delta) => { + if (delta.type !== ID_DELTA) { + throwErrorWrongTypeForAbility( + ID_DELTA, + SupportedPartWhen.StreamDelta, + SupportedPartWhat.ContentBlock + ); + } + + return { text: delta.text }; + }, + }, + + { + id: ID, + when: SupportedPartWhen.StreamStart, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.StreamStart, + SupportedPartWhat.ContentBlock + ); + } + + return { text: contentBlock.text }; + }, + }, + ], +}; diff --git a/js/plugins/anthropic/src/parts/thinking_part.ts b/js/plugins/anthropic/src/parts/thinking_part.ts new file mode 100644 index 0000000000..2bf873ef23 --- /dev/null +++ b/js/plugins/anthropic/src/parts/thinking_part.ts @@ -0,0 +1,105 @@ +/** + * 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 { Part } from 'genkit'; +import { ANTHROPIC_THINKING_CUSTOM_KEY } from '../runner/base'; +import { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID = 'thinking'; +const ID_DELTA = 'thinking_delta'; + +export const ThinkingPart: SupportedPart = { + abilities: [ + { + id: ID, + when: SupportedPartWhen.NonStream, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + } + + return createThinkingPart( + contentBlock.thinking, + contentBlock.signature + ); + }, + }, + + { + id: ID_DELTA, + when: SupportedPartWhen.StreamDelta, + what: SupportedPartWhat.ContentBlock, + func: (delta) => { + if (delta.type !== ID_DELTA) { + throwErrorWrongTypeForAbility( + ID_DELTA, + SupportedPartWhen.StreamDelta, + SupportedPartWhat.ContentBlock + ); + } + + return { reasoning: delta.thinking }; + }, + }, + + { + id: ID, + when: SupportedPartWhen.StreamStart, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.StreamStart, + SupportedPartWhat.ContentBlock + ); + } + + return createThinkingPart( + contentBlock.thinking, + contentBlock.signature + ); + }, + }, + ], +}; + +function createThinkingPart(thinking: string, signature?: string): Part { + const custom = + signature !== undefined + ? { + [ANTHROPIC_THINKING_CUSTOM_KEY]: { signature }, + } + : undefined; + return custom + ? { + reasoning: thinking, + custom, + } + : { + reasoning: thinking, + }; +} diff --git a/js/plugins/anthropic/src/parts/tool_use_part.ts b/js/plugins/anthropic/src/parts/tool_use_part.ts new file mode 100644 index 0000000000..8d65bc4228 --- /dev/null +++ b/js/plugins/anthropic/src/parts/tool_use_part.ts @@ -0,0 +1,74 @@ +/** + * 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 { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID = 'tool_use'; + +export const ToolUsePart: SupportedPart = { + abilities: [ + { + id: ID, + when: SupportedPartWhen.NonStream, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + } + + return { + toolRequest: { + ref: contentBlock.id, + name: contentBlock.name ?? 'unknown_tool', + input: contentBlock.input, + }, + }; + }, + }, + + { + id: ID, + when: SupportedPartWhen.StreamStart, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.StreamStart, + SupportedPartWhat.ContentBlock + ); + } + + return { + toolRequest: { + ref: contentBlock.id, + name: contentBlock.name ?? 'unknown_tool', + input: contentBlock.input, + }, + }; + }, + }, + ], +}; diff --git a/js/plugins/anthropic/src/parts/web_search_tool_result_part.ts b/js/plugins/anthropic/src/parts/web_search_tool_result_part.ts new file mode 100644 index 0000000000..a71dfd4dc2 --- /dev/null +++ b/js/plugins/anthropic/src/parts/web_search_tool_result_part.ts @@ -0,0 +1,89 @@ +/** + * 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 { Part } from 'genkit'; +import { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + throwErrorWrongTypeForAbility, +} from './part'; + +const ID = 'web_search_tool_result'; + +export const WebSearchToolResultPart: SupportedPart = { + abilities: [ + { + id: ID, + when: SupportedPartWhen.NonStream, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + } + + return toWebSearchToolResultPart({ + type: contentBlock.type, + toolUseId: contentBlock.tool_use_id, + content: contentBlock.content, + }); + }, + }, + + { + id: ID, + when: SupportedPartWhen.StreamStart, + what: SupportedPartWhat.ContentBlock, + func: (contentBlock) => { + if (contentBlock.type !== ID) { + throwErrorWrongTypeForAbility( + ID, + SupportedPartWhen.StreamStart, + SupportedPartWhat.ContentBlock + ); + } + + return toWebSearchToolResultPart({ + type: contentBlock.type, + toolUseId: contentBlock.tool_use_id, + content: contentBlock.content, + }); + }, + }, + ], +}; + +function toWebSearchToolResultPart(params: { + toolUseId: string; + content: unknown; + type: string; +}): Part { + const { toolUseId, content, type } = params; + return { + text: `[Anthropic server tool result ${toolUseId}] ${JSON.stringify(content)}`, + custom: { + anthropicServerToolResult: { + type, + toolUseId, + content, + }, + }, + }; +} diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index e6b7132e28..b9abb36716 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -15,7 +15,10 @@ */ import { Anthropic } from '@anthropic-ai/sdk'; -import type { DocumentBlockParam } from '@anthropic-ai/sdk/resources/messages'; +import type { + DocumentBlockParam, + MessageStreamEvent, +} from '@anthropic-ai/sdk/resources/messages'; import type { GenerateRequest, GenerateResponseChunkData, @@ -27,6 +30,18 @@ import type { import { Message as GenkitMessage } from 'genkit'; import type { ToolDefinition } from 'genkit/model'; +import { ContentBlock } from '@anthropic-ai/sdk/resources'; +import { + BetaContentBlock, + BetaRawMessageStreamEvent, +} from '@anthropic-ai/sdk/resources/beta.js'; +import { logger } from 'genkit/logging'; +import { + Ability, + SupportedPart as PluginPart, + SupportedPartWhat, + SupportedPartWhen, +} from '../parts/part.js'; import { AnthropicConfigSchema, Media, @@ -36,7 +51,6 @@ import { type ClaudeRunnerParams, type ThinkingConfig, } from '../types.js'; - import { RunnerContentBlockParam, RunnerMessage, @@ -50,7 +64,7 @@ import { RunnerTypes, } from './types.js'; -const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; +export const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; /** * Shared runner logic for Anthropic SDK integrations. @@ -63,6 +77,7 @@ export abstract class BaseRunner { protected name: string; protected client: Anthropic; protected cacheSystemPrompt?: boolean; + protected supportedParts: PluginPart[] = []; /** * Default maximum output tokens for Claude models when not specified in the request. @@ -298,23 +313,6 @@ export abstract class BaseRunner { }; } - protected createThinkingPart(thinking: string, signature?: string): Part { - const custom = - signature !== undefined - ? { - [ANTHROPIC_THINKING_CUSTOM_KEY]: { signature }, - } - : undefined; - return custom - ? { - reasoning: thinking, - custom, - } - : { - reasoning: thinking, - }; - } - protected getThinkingSignature(part: Part): string | undefined { const custom = part.custom as Record | undefined; const thinkingValue = custom?.[ANTHROPIC_THINKING_CUSTOM_KEY]; @@ -363,24 +361,6 @@ export abstract class BaseRunner { return undefined; } - protected toWebSearchToolResultPart(params: { - toolUseId: string; - content: unknown; - type: string; - }): Part { - const { toolUseId, content, type } = params; - return { - text: `[Anthropic server tool result ${toolUseId}] ${JSON.stringify(content)}`, - custom: { - anthropicServerToolResult: { - type, - toolUseId, - content, - }, - }, - }; - } - /** * Converts a Genkit Part to the corresponding Anthropic content block. * Each runner implements this to return its specific API type. @@ -452,6 +432,67 @@ export abstract class BaseRunner { return { system, messages: anthropicMsgs }; } + protected fromAnthropicContentBlockChunk( + event: MessageStreamEvent | BetaRawMessageStreamEvent + ): Part | undefined { + // Handle content_block_start events + if (event.type === 'content_block_start') { + const contentBlock = event.content_block; + + const foundSupportedPartAbility = this.findSupportedPartAbility( + contentBlock.type, + SupportedPartWhen.StreamStart, + SupportedPartWhat.ContentBlock + ); + if (foundSupportedPartAbility) { + return foundSupportedPartAbility.func(contentBlock); + } + + const unknownType = (contentBlock as { type: string }).type; + logger.warn( + `Unexpected Anthropic content block type in stream: ${unknownType}. Returning undefined. Content block: ${JSON.stringify(contentBlock)}` + ); + return undefined; + } + + // Handle content_block_delta events + if (event.type === 'content_block_delta') { + const foundSupportedPartAbility = this.findSupportedPartAbility( + event.delta.type, + SupportedPartWhen.StreamDelta, + SupportedPartWhat.ContentBlock + ); + if (foundSupportedPartAbility) { + return foundSupportedPartAbility.func(event.delta); + } + + // signature_delta - ignore + return undefined; + } + + // Other event types (message_start, message_delta, etc.) - ignore + return undefined; + } + + protected fromAnthropicContentBlock( + contentBlock: ContentBlock | BetaContentBlock + ): Part { + const foundSupportedPartAbility = this.findSupportedPartAbility( + contentBlock.type, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + if (foundSupportedPartAbility) { + return foundSupportedPartAbility.func(contentBlock); + } + + const unknownType = (contentBlock as { type: string }).type; + logger.warn( + `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` + ); + return { text: '' }; + } + /** * Converts a Genkit ToolDefinition to an Anthropic Tool object. */ @@ -463,6 +504,25 @@ export abstract class BaseRunner { } as RunnerTool; } + protected findSupportedPartAbility( + type: string, + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen], + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat] + ): Ability | undefined { + for (const part of this.supportedParts) { + for (const ability of part.abilities) { + if ( + ability.when === when && + ability.what === what && + ability.id === type + ) { + return ability; + } + } + } + return undefined; + } + /** * Converts an Anthropic request to a non-streaming Anthropic API request body. * @param modelName The name of the Anthropic model to use. diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index bb4f86b125..35f226cb6c 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -43,7 +43,16 @@ import type { import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; +import { McpToolUsePart } from '../parts/mcp_tool_use.js'; +import { SupportedPartWhat, SupportedPartWhen } from '../parts/part.js'; +import { RedactedThinkingPart } from '../parts/redacted_thinking_part.js'; +import { ServerToolUsePart } from '../parts/server_tool_use_part.js'; +import { TextPart } from '../parts/text_part.js'; +import { ThinkingPart } from '../parts/thinking_part.js'; +import { ToolUsePart } from '../parts/tool_use_part.js'; +import { WebSearchToolResultPart } from '../parts/web_search_tool_result_part.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; +import { unsupportedBetaServerToolError } from '../utils.js'; import { BaseRunner } from './base.js'; import { RunnerTypes } from './types.js'; @@ -117,9 +126,6 @@ function toAnthropicSchema( return out; } -const unsupportedServerToolError = (blockType: string): string => - `Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.`; - interface BetaRunnerTypes extends RunnerTypes { Message: BetaMessage; Stream: BetaMessageStream; @@ -145,6 +151,16 @@ interface BetaRunnerTypes extends RunnerTypes { export class BetaRunner extends BaseRunner { constructor(params: ClaudeRunnerParams) { super(params); + + this.supportedParts = [ + RedactedThinkingPart, + ServerToolUsePart, + TextPart, + ThinkingPart, + ToolUsePart, + WebSearchToolResultPart, + McpToolUsePart, + ]; } /** @@ -423,7 +439,7 @@ export class BetaRunner extends BaseRunner { message: { role: 'model', content: message.content.map((block) => - this.fromBetaContentBlock(block) + this.fromAnthropicContentBlock(block) ), }, }, @@ -443,9 +459,9 @@ export class BetaRunner extends BaseRunner { blockType && BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(blockType) ) { - throw new Error(unsupportedServerToolError(blockType)); + throw new Error(unsupportedBetaServerToolError(blockType)); } - return this.fromBetaContentBlock(event.content_block); + return this.fromAnthropicContentBlock(event.content_block); } if (event.type === 'content_block_delta') { if (event.delta.type === 'text_delta') { @@ -460,71 +476,26 @@ export class BetaRunner extends BaseRunner { return undefined; } - private fromBetaContentBlock(contentBlock: BetaContentBlock): Part { - switch (contentBlock.type) { - case 'tool_use': { - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name ?? 'unknown_tool', - input: contentBlock.input, - }, - }; - } - - case 'mcp_tool_use': - throw new Error(unsupportedServerToolError(contentBlock.type)); - - case 'server_tool_use': { - const baseName = contentBlock.name ?? 'unknown_tool'; - const serverToolName = - 'server_name' in contentBlock && contentBlock.server_name - ? `${contentBlock.server_name}/${baseName}` - : baseName; - return { - text: `[Anthropic server tool ${serverToolName}] input: ${JSON.stringify(contentBlock.input)}`, - custom: { - anthropicServerToolUse: { - id: contentBlock.id, - name: serverToolName, - input: contentBlock.input, - }, - }, - }; - } - - case 'web_search_tool_result': - return this.toWebSearchToolResultPart({ - type: contentBlock.type, - toolUseId: contentBlock.tool_use_id, - content: contentBlock.content, - }); - - case 'text': - return { text: contentBlock.text }; - - case 'thinking': - return this.createThinkingPart( - contentBlock.thinking, - contentBlock.signature - ); + override fromAnthropicContentBlock(contentBlock: BetaContentBlock): Part { + const foundSupportedPartAbility = this.findSupportedPartAbility( + contentBlock.type, + SupportedPartWhen.NonStream, + SupportedPartWhat.ContentBlock + ); + if (foundSupportedPartAbility) { + return foundSupportedPartAbility.func(contentBlock); + } - case 'redacted_thinking': - return { custom: { redactedThinking: contentBlock.data } }; - - default: { - if (BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(contentBlock.type)) { - throw new Error(unsupportedServerToolError(contentBlock.type)); - } - const unknownType = (contentBlock as { type: string }).type; - logger.warn( - `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify( - contentBlock - )}` - ); - return { text: '' }; - } + if (BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(contentBlock.type)) { + throw new Error(unsupportedBetaServerToolError(contentBlock.type)); } + const unknownType = (contentBlock as { type: string }).type; + logger.warn( + `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify( + contentBlock + )}` + ); + return { text: '' }; } private fromBetaStopReason( diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 0c8f7ffc4f..b5c6bef63f 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -16,7 +16,6 @@ import { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream.js'; import type { - ContentBlock, DocumentBlockParam, ImageBlockParam, Message, @@ -38,9 +37,15 @@ import type { ModelResponseData, Part, } from 'genkit'; -import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; +import { InputJsonPart } from '../parts/input_json_part.js'; +import { RedactedThinkingPart } from '../parts/redacted_thinking_part.js'; +import { ServerToolUsePart } from '../parts/server_tool_use_part.js'; +import { TextPart } from '../parts/text_part.js'; +import { ThinkingPart } from '../parts/thinking_part.js'; +import { ToolUsePart } from '../parts/tool_use_part.js'; +import { WebSearchToolResultPart } from '../parts/web_search_tool_result_part.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { BaseRunner } from './base.js'; import { RunnerTypes as BaseRunnerTypes } from './types.js'; @@ -66,6 +71,15 @@ interface RunnerTypes extends BaseRunnerTypes { export class Runner extends BaseRunner { constructor(params: ClaudeRunnerParams) { super(params); + this.supportedParts = [ + InputJsonPart, + RedactedThinkingPart, + ServerToolUsePart, + TextPart, + ThinkingPart, + ToolUsePart, + WebSearchToolResultPart, + ]; } protected toAnthropicMessageContent( @@ -338,139 +352,6 @@ export class Runner extends BaseRunner { return this.fromAnthropicContentBlockChunk(event); } - protected fromAnthropicContentBlockChunk( - event: MessageStreamEvent - ): Part | undefined { - // Handle content_block_delta events - if (event.type === 'content_block_delta') { - const delta = event.delta; - - if (delta.type === 'input_json_delta') { - throw new Error( - 'Anthropic streaming tool input (input_json_delta) is not yet supported. Please disable streaming or upgrade this plugin.' - ); - } - - if (delta.type === 'text_delta') { - return { text: delta.text }; - } - - if (delta.type === 'thinking_delta') { - return { reasoning: delta.thinking }; - } - - // signature_delta - ignore - return undefined; - } - - // Handle content_block_start events - if (event.type === 'content_block_start') { - const block = event.content_block; - - switch (block.type) { - case 'server_tool_use': - return { - text: `[Anthropic server tool ${block.name}] input: ${JSON.stringify(block.input)}`, - custom: { - anthropicServerToolUse: { - id: block.id, - name: block.name, - input: block.input, - }, - }, - }; - - case 'web_search_tool_result': - return this.toWebSearchToolResultPart({ - type: block.type, - toolUseId: block.tool_use_id, - content: block.content, - }); - - case 'text': - return { text: block.text }; - - case 'thinking': - return this.createThinkingPart(block.thinking, block.signature); - - case 'redacted_thinking': - return { custom: { redactedThinking: block.data } }; - - case 'tool_use': - return { - toolRequest: { - ref: block.id, - name: block.name, - input: block.input, - }, - }; - - default: { - const unknownType = (block as { type: string }).type; - logger.warn( - `Unexpected Anthropic content block type in stream: ${unknownType}. Returning undefined. Content block: ${JSON.stringify(block)}` - ); - return undefined; - } - } - } - - // Other event types (message_start, message_delta, etc.) - ignore - return undefined; - } - - protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { - switch (contentBlock.type) { - case 'server_tool_use': - return { - text: `[Anthropic server tool ${contentBlock.name}] input: ${JSON.stringify(contentBlock.input)}`, - custom: { - anthropicServerToolUse: { - id: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }, - }; - - case 'web_search_tool_result': - return this.toWebSearchToolResultPart({ - type: contentBlock.type, - toolUseId: contentBlock.tool_use_id, - content: contentBlock.content, - }); - - case 'tool_use': - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }; - - case 'text': - return { text: contentBlock.text }; - - case 'thinking': - return this.createThinkingPart( - contentBlock.thinking, - contentBlock.signature - ); - - case 'redacted_thinking': - return { custom: { redactedThinking: contentBlock.data } }; - - default: { - const unknownType = (contentBlock as { type: string }).type; - logger.warn( - `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` - ); - return { text: '' }; - } - } - } - protected fromAnthropicStopReason( reason: Message['stop_reason'] ): ModelResponseData['finishReason'] { diff --git a/js/plugins/anthropic/src/utils.ts b/js/plugins/anthropic/src/utils.ts new file mode 100644 index 0000000000..c8b0f24419 --- /dev/null +++ b/js/plugins/anthropic/src/utils.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +export const unsupportedBetaServerToolError = (blockType: string): string => + `Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.`; diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 0d549b938c..e472fbafb5 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -709,7 +709,7 @@ describe('BetaRunner', () => { assert.throws( () => - exposed.fromBetaContentBlock({ + exposed.fromAnthropicContentBlock({ type: 'mcp_tool_use', id: 'toolu_unknown', input: {}, @@ -725,7 +725,7 @@ describe('BetaRunner', () => { client: mockClient as Anthropic, }); - const thinkingPart = (runner as any).fromBetaContentBlock({ + const thinkingPart = (runner as any).fromAnthropicContentBlock({ type: 'thinking', thinking: 'pondering', signature: 'sig_456', @@ -735,7 +735,7 @@ describe('BetaRunner', () => { custom: { anthropicThinking: { signature: 'sig_456' } }, }); - const redactedPart = (runner as any).fromBetaContentBlock({ + const redactedPart = (runner as any).fromAnthropicContentBlock({ type: 'redacted_thinking', data: '[redacted]', }); @@ -743,7 +743,7 @@ describe('BetaRunner', () => { custom: { redactedThinking: '[redacted]' }, }); - const toolPart = (runner as any).fromBetaContentBlock({ + const toolPart = (runner as any).fromAnthropicContentBlock({ type: 'tool_use', id: 'toolu_x', name: 'plainTool', @@ -757,7 +757,7 @@ describe('BetaRunner', () => { }, }); - const serverToolPart = (runner as any).fromBetaContentBlock({ + const serverToolPart = (runner as any).fromAnthropicContentBlock({ type: 'server_tool_use', id: 'srv_tool_1', name: 'serverTool', @@ -776,7 +776,7 @@ describe('BetaRunner', () => { }); const warnMock = mock.method(console, 'warn', () => {}); - const fallbackPart = (runner as any).fromBetaContentBlock({ + const fallbackPart = (runner as any).fromAnthropicContentBlock({ type: 'mystery', }); assert.deepStrictEqual(fallbackPart, { text: '' });