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..acc253a295 --- /dev/null +++ b/js/plugins/anthropic/src/parts/input_json_part.ts @@ -0,0 +1,40 @@ +/** + * 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 { InputJSONDelta } from '@anthropic-ai/sdk/resources'; +import { + createAbility, + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, +} from './part'; + +const ID_DELTA = 'input_json_delta'; + +export const InputJsonPart: SupportedPart = { + abilities: [ + createAbility({ + id: [ID_DELTA], + when: [SupportedPartWhen.StreamDelta], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, _delta) => { + 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/part.ts b/js/plugins/anthropic/src/parts/part.ts new file mode 100644 index 0000000000..94ffad1463 --- /dev/null +++ b/js/plugins/anthropic/src/parts/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 { + 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'; +import type { Part } from 'genkit'; + +export interface SupportedPart { + abilities: Ability[]; +} + +export interface Ability { + id: string[]; + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen][]; + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat][]; + func: ( + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen], + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat], + chunk: + | MessageStreamEvent + | BetaRawMessageStreamEvent + | ContentBlock + | BetaContentBlock + | RawContentBlockDelta + | BetaRawContentBlockDelta + ) => Part; +} + +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, + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen], + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat] +): never { + throw new Error( + `Part '${partId}' is not supported for ${String(when) + .replace(/([A-Z])/g, ' $1') + .toLowerCase()} in ${what}` + ); +} + +function validatePartType( + expectedTypes: string | string[], + chunk: unknown, + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen], + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat] +): asserts chunk is T { + const types = Array.isArray(expectedTypes) ? expectedTypes : [expectedTypes]; + const chunkWithType = chunk as { type: string }; + if (!types.includes(chunkWithType.type)) { + throwErrorWrongTypeForAbility(types[0], when, what); + } +} + +export function createAbility(config: { + id: string[]; + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen][]; + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat][]; + func: ( + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen], + what: (typeof SupportedPartWhat)[keyof typeof SupportedPartWhat], + chunk: T + ) => Part; +}): Ability { + return { + id: config.id, + when: config.when, + what: config.what, + func: (when, what, chunk) => { + validatePartType(config.id, chunk, when, what); + return config.func(when, what, chunk); + }, + }; +} 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..f93c64861d --- /dev/null +++ b/js/plugins/anthropic/src/parts/redacted_thinking_part.ts @@ -0,0 +1,38 @@ +/** + * 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 { RedactedThinkingBlock } from '@anthropic-ai/sdk/resources'; +import { + createAbility, + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, +} from './part'; + +const ID = 'redacted_thinking'; + +export const RedactedThinkingPart: SupportedPart = { + abilities: [ + createAbility({ + id: [ID], + when: [SupportedPartWhen.NonStream, SupportedPartWhen.StreamStart], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, 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..90dbda3390 --- /dev/null +++ b/js/plugins/anthropic/src/parts/server_tool_use_part.ts @@ -0,0 +1,53 @@ +/** + * 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 { ServerToolUseBlock } from '@anthropic-ai/sdk/resources'; +import { + createAbility, + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, +} from './part'; + +const ID = 'server_tool_use'; + +export const ServerToolUsePart: SupportedPart = { + abilities: [ + createAbility({ + id: [ID], + when: [SupportedPartWhen.NonStream, SupportedPartWhen.StreamStart], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, 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, + }, + }, + }; + }, + }), + ], +}; 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..542c953115 --- /dev/null +++ b/js/plugins/anthropic/src/parts/text_part.ts @@ -0,0 +1,43 @@ +/** + * 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 { TextBlock, TextDelta } from '@anthropic-ai/sdk/resources'; +import { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + createAbility, +} from './part'; + +const ID = 'text'; +const ID_DELTA = 'text_delta'; + +export const TextPart: SupportedPart = { + abilities: [ + createAbility({ + id: [ID, ID_DELTA], + when: [ + SupportedPartWhen.NonStream, + SupportedPartWhen.StreamDelta, + SupportedPartWhen.StreamStart, + ], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, content) => { + return { text: content.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..0221162a36 --- /dev/null +++ b/js/plugins/anthropic/src/parts/thinking_part.ts @@ -0,0 +1,70 @@ +/** + * 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 { ThinkingBlock, ThinkingDelta } from '@anthropic-ai/sdk/resources'; +import { Part } from 'genkit'; +import { ANTHROPIC_THINKING_CUSTOM_KEY } from '../runner/base'; +import { + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, + createAbility, +} from './part'; + +const ID = 'thinking'; +const ID_DELTA = 'thinking_delta'; + +export const ThinkingPart: SupportedPart = { + abilities: [ + createAbility({ + id: [ID], + when: [SupportedPartWhen.NonStream, SupportedPartWhen.StreamStart], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, contentBlock) => { + return createThinkingPart( + contentBlock.thinking, + contentBlock.signature + ); + }, + }), + + createAbility({ + id: [ID_DELTA], + when: [SupportedPartWhen.StreamDelta], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, delta) => { + return { reasoning: delta.thinking }; + }, + }), + ], +}; + +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..4522b25eda --- /dev/null +++ b/js/plugins/anthropic/src/parts/tool_use_part.ts @@ -0,0 +1,44 @@ +/** + * 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 { ToolUseBlock } from '@anthropic-ai/sdk/resources'; +import { + createAbility, + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, +} from './part'; + +const ID = 'tool_use'; + +export const ToolUsePart: SupportedPart = { + abilities: [ + createAbility({ + id: [ID], + when: [SupportedPartWhen.NonStream, SupportedPartWhen.StreamStart], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, 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..4da69f8f44 --- /dev/null +++ b/js/plugins/anthropic/src/parts/web_search_tool_result_part.ts @@ -0,0 +1,61 @@ +/** + * 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 { WebSearchToolResultBlock } from '@anthropic-ai/sdk/resources'; +import { Part } from 'genkit'; +import { + createAbility, + SupportedPart, + SupportedPartWhat, + SupportedPartWhen, +} from './part'; + +const ID = 'web_search_tool_result'; + +export const WebSearchToolResultPart: SupportedPart = { + abilities: [ + createAbility({ + id: [ID], + when: [SupportedPartWhen.NonStream, SupportedPartWhen.StreamStart], + what: [SupportedPartWhat.ContentBlock], + func: (_when, _what, 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..e3080935c1 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,21 +51,19 @@ import { type ClaudeRunnerParams, type ThinkingConfig, } from '../types.js'; - import { RunnerContentBlockParam, RunnerMessage, RunnerMessageParam, RunnerRequestBody, RunnerStream, - RunnerStreamEvent, RunnerStreamingRequestBody, RunnerTool, RunnerToolResponseContent, RunnerTypes, } from './types.js'; -const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; +export const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; /** * Shared runner logic for Anthropic SDK integrations. @@ -62,7 +75,10 @@ const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; export abstract class BaseRunner { protected name: string; protected client: Anthropic; + protected isBeta?: boolean; protected cacheSystemPrompt?: boolean; + protected supportedParts: PluginPart[] = []; + protected unsupportedServerToolBlockTypes: Set = new Set(); /** * Default maximum output tokens for Claude models when not specified in the request. @@ -298,23 +314,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 +362,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 +433,75 @@ export abstract class BaseRunner { return { system, messages: anthropicMsgs }; } + protected fromAnthropicContentBlockChunk( + event: MessageStreamEvent | BetaRawMessageStreamEvent + ): Part | undefined { + if (event.type === 'content_block_start') { + return this.convertContentBlock( + event.content_block, + SupportedPartWhen.StreamStart, + true + ); + } + + if (event.type === 'content_block_delta') { + return this.convertContentBlock( + event.delta, + SupportedPartWhen.StreamDelta, + true + ); + } + + return undefined; + } + + protected fromAnthropicContentBlock( + contentBlock: ContentBlock | BetaContentBlock + ): Part { + return ( + this.convertContentBlock( + contentBlock, + SupportedPartWhen.NonStream, + false + ) ?? { text: '' } + ); + } + + private convertContentBlock( + chunk: + | MessageStreamEvent + | BetaRawMessageStreamEvent + | ContentBlock + | BetaContentBlock + | { type: string }, + when: (typeof SupportedPartWhen)[keyof typeof SupportedPartWhen], + isStream: boolean + ): Part | undefined { + const chunkType = (chunk as { type: string }).type; + + if (this.unsupportedServerToolBlockTypes.has(chunkType)) { + throw new Error( + `Anthropic ${this.isBeta ? 'beta' : 'stable'} runner does not yet support server-managed tool block '${chunkType}'.` + ); + } + + const ability = this.findSupportedPartAbility( + chunkType, + when, + SupportedPartWhat.ContentBlock + ); + + if (ability) { + return ability.func(when, SupportedPartWhat.ContentBlock, chunk as never); + } + + logger.warn( + `Unexpected Anthropic content block type${isStream ? ' in stream' : ''}: ${chunkType}. ${isStream ? 'Returning undefined' : 'Returning empty text'}. Content block: ${JSON.stringify(chunk)}` + ); + + return isStream ? undefined : { text: '' }; + } + /** * Converts a Genkit ToolDefinition to an Anthropic Tool object. */ @@ -463,6 +513,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.includes(when) && + ability.what.includes(what) && + ability.id.includes(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. @@ -501,14 +570,10 @@ export abstract class BaseRunner { abortSignal: AbortSignal ): RunnerStream; - protected abstract toGenkitResponse( + protected abstract fromAnthropicResponse( message: RunnerMessage ): GenerateResponseData; - protected abstract toGenkitPart( - event: RunnerStreamEvent - ): Part | undefined; - public async run( request: GenerateRequest, options: { @@ -527,7 +592,9 @@ export abstract class BaseRunner { ); const stream = this.streamMessages(body, abortSignal); for await (const event of stream) { - const part = this.toGenkitPart(event); + const part = this.fromAnthropicContentBlockChunk( + event as MessageStreamEvent | BetaRawMessageStreamEvent + ); if (part) { sendChunk({ index: 0, @@ -536,7 +603,7 @@ export abstract class BaseRunner { } } const finalMessage = await stream.finalMessage(); - return this.toGenkitResponse(finalMessage); + return this.fromAnthropicResponse(finalMessage); } const body = this.toAnthropicRequestBody( @@ -545,6 +612,6 @@ export abstract class BaseRunner { this.cacheSystemPrompt ); const response = await this.createMessage(body, abortSignal); - return this.toGenkitResponse(response); + return this.fromAnthropicResponse(response); } } diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index bb4f86b125..6eeeea7006 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -16,7 +16,6 @@ import { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream.js'; import type { - BetaContentBlock, BetaImageBlockParam, BetaMessage, MessageCreateParams as BetaMessageCreateParams, @@ -40,32 +39,18 @@ import type { ModelResponseData, Part, } from 'genkit'; -import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.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 } from './types.js'; -/** - * Server-managed tool blocks emitted by the beta API that Genkit cannot yet - * interpret. We fail fast on these so callers do not accidentally treat them as - * locally executable tool invocations. - */ -/** - * Server tool types that exist in beta but are not yet supported. - * Note: server_tool_use and web_search_tool_result ARE supported (same as stable API). - */ -const BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES = new Set([ - 'web_fetch_tool_result', - 'code_execution_tool_result', - 'bash_code_execution_tool_result', - 'text_editor_code_execution_tool_result', - 'mcp_tool_result', - 'mcp_tool_use', - 'container_upload', -]); - const BETA_APIS = [ // 'message-batches-2024-09-24', // 'prompt-caching-2024-07-31', @@ -117,9 +102,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 +127,25 @@ interface BetaRunnerTypes extends RunnerTypes { export class BetaRunner extends BaseRunner { constructor(params: ClaudeRunnerParams) { super(params); + + this.isBeta = true; + this.supportedParts = [ + RedactedThinkingPart, + ServerToolUsePart, + TextPart, + ThinkingPart, + ToolUsePart, + WebSearchToolResultPart, + ]; + this.unsupportedServerToolBlockTypes.add('web_fetch_tool_result'); + this.unsupportedServerToolBlockTypes.add('code_execution_tool_result'); + this.unsupportedServerToolBlockTypes.add('bash_code_execution_tool_result'); + this.unsupportedServerToolBlockTypes.add( + 'text_editor_code_execution_tool_result' + ); + this.unsupportedServerToolBlockTypes.add('mcp_tool_result'); + this.unsupportedServerToolBlockTypes.add('mcp_tool_use'); + this.unsupportedServerToolBlockTypes.add('container_upload'); } /** @@ -414,7 +415,7 @@ export class BetaRunner extends BaseRunner { return body; } - protected toGenkitResponse(message: BetaMessage): GenerateResponseData { + protected fromAnthropicResponse(message: BetaMessage): GenerateResponseData { return { candidates: [ { @@ -423,7 +424,7 @@ export class BetaRunner extends BaseRunner { message: { role: 'model', content: message.content.map((block) => - this.fromBetaContentBlock(block) + this.fromAnthropicContentBlock(block) ), }, }, @@ -436,97 +437,6 @@ export class BetaRunner extends BaseRunner { }; } - protected toGenkitPart(event: BetaRawMessageStreamEvent): Part | undefined { - if (event.type === 'content_block_start') { - const blockType = (event.content_block as { type?: string }).type; - if ( - blockType && - BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(blockType) - ) { - throw new Error(unsupportedServerToolError(blockType)); - } - return this.fromBetaContentBlock(event.content_block); - } - if (event.type === 'content_block_delta') { - if (event.delta.type === 'text_delta') { - return { text: event.delta.text }; - } - if (event.delta.type === 'thinking_delta') { - return { reasoning: event.delta.thinking }; - } - // server/client tool input_json_delta not supported yet - return undefined; - } - 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 - ); - - 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: '' }; - } - } - } - private fromBetaStopReason( reason: BetaStopReason | null ): ModelResponseData['finishReason'] { diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 0c8f7ffc4f..c17a340839 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,17 @@ interface RunnerTypes extends BaseRunnerTypes { export class Runner extends BaseRunner { constructor(params: ClaudeRunnerParams) { super(params); + this.isBeta = false; + this.supportedParts = [ + InputJsonPart, + RedactedThinkingPart, + ServerToolUsePart, + TextPart, + ThinkingPart, + ToolUsePart, + WebSearchToolResultPart, + ]; + this.unsupportedServerToolBlockTypes.add('mcp_tool_use'); } protected toAnthropicMessageContent( @@ -330,145 +346,26 @@ export class Runner extends BaseRunner { return this.client.messages.stream(body, { signal: abortSignal }); } - protected toGenkitResponse(message: Message): GenerateResponseData { - return this.fromAnthropicResponse(message); - } - - protected toGenkitPart(event: MessageStreamEvent): Part | undefined { - 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, + protected fromAnthropicResponse(message: Message): GenerateResponseData { + return { + candidates: [ + { + index: 0, + finishReason: this.fromAnthropicStopReason(message.stop_reason), + message: { + role: 'model', + content: message.content.map((block) => + this.fromAnthropicContentBlock(block) + ), }, - }; - - 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: '' }; - } - } + }, + ], + usage: { + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + }, + custom: message, + }; } protected fromAnthropicStopReason( @@ -489,26 +386,4 @@ export class Runner extends BaseRunner { return 'other'; } } - - protected fromAnthropicResponse(response: Message): GenerateResponseData { - return { - candidates: [ - { - index: 0, - finishReason: this.fromAnthropicStopReason(response.stop_reason), - message: { - role: 'model', - content: response.content.map((block) => - this.fromAnthropicContentBlock(block) - ), - }, - }, - ], - usage: { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - }, - custom: response, - }; - } } diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 0d549b938c..74fa097f89 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -300,7 +300,7 @@ describe('BetaRunner', () => { }); const exposed = runner as any; - const textPart = exposed.toGenkitPart({ + const textPart = exposed.fromAnthropicContentBlockChunk({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: 'hi' }, @@ -318,7 +318,7 @@ describe('BetaRunner', () => { server_name: 'srv', }, } as any; - const toolPart = exposed.toGenkitPart(serverToolEvent); + const toolPart = exposed.fromAnthropicContentBlockChunk(serverToolEvent); assert.deepStrictEqual(toolPart, { text: '[Anthropic server tool srv/myTool] input: {"foo":"bar"}', custom: { @@ -330,14 +330,16 @@ describe('BetaRunner', () => { }, }); - const deltaPart = exposed.toGenkitPart({ + const deltaPart = exposed.fromAnthropicContentBlockChunk({ type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'hmm' }, } as any); assert.deepStrictEqual(deltaPart, { reasoning: 'hmm' }); - const ignored = exposed.toGenkitPart({ type: 'message_stop' } as any); + const ignored = exposed.fromAnthropicContentBlockChunk({ + type: 'message_stop', + } as any); assert.strictEqual(ignored, undefined); }); @@ -351,7 +353,7 @@ describe('BetaRunner', () => { const exposed = runner as any; assert.throws( () => - exposed.toGenkitPart({ + exposed.fromAnthropicContentBlockChunk({ type: 'content_block_start', index: 0, content_block: { @@ -709,7 +711,7 @@ describe('BetaRunner', () => { assert.throws( () => - exposed.fromBetaContentBlock({ + exposed.fromAnthropicContentBlock({ type: 'mcp_tool_use', id: 'toolu_unknown', input: {}, @@ -725,7 +727,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 +737,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 +745,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 +759,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 +778,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: '' });