diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index e6b7132e28..36c5a8a175 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -50,8 +50,6 @@ import { RunnerTypes, } from './types.js'; -const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; - /** * Shared runner logic for Anthropic SDK integrations. * @@ -298,35 +296,11 @@ 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]; - if ( - typeof thinkingValue === 'object' && - thinkingValue !== null && - 'signature' in thinkingValue && - typeof (thinkingValue as { signature: unknown }).signature === 'string' - ) { - return (thinkingValue as { signature: string }).signature; - } - return undefined; + const metadata = part.metadata as Record | undefined; + return typeof metadata?.thoughtSignature === 'string' + ? metadata.thoughtSignature + : undefined; } protected getRedactedThinkingData(part: Part): string | undefined { @@ -363,24 +337,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. diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 099a589909..4efcd1fd13 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -46,27 +46,22 @@ import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; +import { + betaServerToolUseBlockToPart, + unsupportedServerToolError, +} from './converters/beta.js'; +import { + inputJsonDeltaError, + redactedThinkingBlockToPart, + textBlockToPart, + textDeltaToPart, + thinkingBlockToPart, + thinkingDeltaToPart, + toolUseBlockToPart, + webSearchToolResultBlockToPart, +} from './converters/shared.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', @@ -118,9 +113,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; @@ -167,7 +159,7 @@ export class BetaRunner extends BaseRunner { const signature = this.getThinkingSignature(part); if (!signature) { throw new Error( - 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `custom.anthropicThinking.signature` value from the original response.' + 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `metadata.thoughtSignature` value from the original response.' ); } return { @@ -462,23 +454,19 @@ 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 }; + return textDeltaToPart(event.delta); } if (event.delta.type === 'thinking_delta') { - return { reasoning: event.delta.thinking }; + return thinkingDeltaToPart(event.delta); } - // server/client tool input_json_delta not supported yet + if (event.delta.type === 'input_json_delta') { + throw inputJsonDeltaError(); + } + // signature_delta - ignore return undefined; } return undefined; @@ -486,65 +474,44 @@ export class BetaRunner extends BaseRunner { 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 'text': + return textBlockToPart(contentBlock); - case 'web_search_tool_result': - return this.toWebSearchToolResultPart({ - type: contentBlock.type, - toolUseId: contentBlock.tool_use_id, - content: contentBlock.content, + case 'tool_use': + // Beta API may have undefined name, fallback to 'unknown_tool' + return toolUseBlockToPart({ + id: contentBlock.id, + name: contentBlock.name ?? 'unknown_tool', + input: contentBlock.input, }); - case 'text': - return { text: contentBlock.text }; - case 'thinking': - return this.createThinkingPart( - contentBlock.thinking, - contentBlock.signature - ); + return thinkingBlockToPart(contentBlock); case 'redacted_thinking': - return { custom: { redactedThinking: contentBlock.data } }; + return redactedThinkingBlockToPart(contentBlock); + + case 'server_tool_use': + return betaServerToolUseBlockToPart(contentBlock); + + case 'web_search_tool_result': + return webSearchToolResultBlockToPart(contentBlock); + + // Unsupported beta server tool types + case 'mcp_tool_use': + case 'mcp_tool_result': + case 'web_fetch_tool_result': + case 'code_execution_tool_result': + case 'bash_code_execution_tool_result': + case 'text_editor_code_execution_tool_result': + case 'container_upload': + case 'tool_search_tool_result': + throw new Error(unsupportedServerToolError(contentBlock.type)); 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 - )}` + `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` ); return { text: '' }; } diff --git a/js/plugins/anthropic/src/runner/converters/beta.ts b/js/plugins/anthropic/src/runner/converters/beta.ts new file mode 100644 index 0000000000..c597dbb848 --- /dev/null +++ b/js/plugins/anthropic/src/runner/converters/beta.ts @@ -0,0 +1,54 @@ +/** + * 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. + */ + +/** + * Converters for beta API content blocks. + */ + +import type { Part } from 'genkit'; + +/** + * Converts a server_tool_use block to a Genkit Part. + * In the beta API, name may be undefined and server_name prefix is supported. + */ +export function betaServerToolUseBlockToPart(block: { + id: string; + name?: string; + input: unknown; + server_name?: string; +}): Part { + const baseName = block.name ?? 'unknown_tool'; + const serverToolName = block.server_name + ? `${block.server_name}/${baseName}` + : baseName; + return { + text: `[Anthropic server tool ${serverToolName}] input: ${JSON.stringify(block.input)}`, + metadata: { + anthropicServerToolUse: { + id: block.id, + name: serverToolName, + input: block.input, + }, + }, + }; +} + +/** + * Error message for unsupported server tool block types in the beta API. + */ +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.`; +} diff --git a/js/plugins/anthropic/src/runner/converters/shared.ts b/js/plugins/anthropic/src/runner/converters/shared.ts new file mode 100644 index 0000000000..6d6faf6091 --- /dev/null +++ b/js/plugins/anthropic/src/runner/converters/shared.ts @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/** + * Shared utilities for converting Anthropic content blocks to Genkit Parts. + * Uses structural typing so both stable and beta API types work with these functions. + */ + +import type { Part } from 'genkit'; + +/** + * Converts a text block to a Genkit Part. + */ +export function textBlockToPart(block: { text: string }): Part { + return { text: block.text }; +} + +/** + * Converts a tool_use block to a Genkit Part. + */ +export function toolUseBlockToPart(block: { + id: string; + name: string; + input: unknown; +}): Part { + return { + toolRequest: { + ref: block.id, + name: block.name, + input: block.input, + }, + }; +} + +/** + * Converts a thinking block to a Genkit Part, including signature metadata if present. + */ +export function thinkingBlockToPart(block: { + thinking: string; + signature?: string; +}): Part { + if (block.signature !== undefined) { + return { + reasoning: block.thinking, + metadata: { thoughtSignature: block.signature }, + }; + } + return { reasoning: block.thinking }; +} + +/** + * Converts a redacted thinking block to a Genkit Part. + */ +export function redactedThinkingBlockToPart(block: { data: string }): Part { + return { custom: { redactedThinking: block.data } }; +} + +/** + * Converts a web_search_tool_result block to a Genkit Part. + */ +export function webSearchToolResultBlockToPart(block: { + tool_use_id: string; + content: unknown; +}): Part { + return { + text: `[Anthropic server tool result ${block.tool_use_id}] ${JSON.stringify(block.content)}`, + metadata: { + anthropicServerToolResult: { + type: 'web_search_tool_result', + toolUseId: block.tool_use_id, + content: block.content, + }, + }, + }; +} + +// --- Delta converters for streaming --- + +/** + * Converts a text_delta to a Genkit Part. + */ +export function textDeltaToPart(delta: { text: string }): Part { + return { text: delta.text }; +} + +/** + * Converts a thinking_delta to a Genkit Part. + */ +export function thinkingDeltaToPart(delta: { thinking: string }): Part { + return { reasoning: delta.thinking }; +} + +/** + * Error for unsupported input_json_delta in streaming. + */ +export function inputJsonDeltaError(): Error { + return new Error( + 'Anthropic streaming tool input (input_json_delta) is not yet supported. Please disable streaming or upgrade this plugin.' + ); +} diff --git a/js/plugins/anthropic/src/runner/converters/stable.ts b/js/plugins/anthropic/src/runner/converters/stable.ts new file mode 100644 index 0000000000..d6b3508d42 --- /dev/null +++ b/js/plugins/anthropic/src/runner/converters/stable.ts @@ -0,0 +1,42 @@ +/** + * 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. + */ + +/** + * Converters for stable API content blocks. + */ + +import type { Part } from 'genkit'; + +/** + * Converts a server_tool_use block to a Genkit Part. + * In the stable API, name is always present. + */ +export function serverToolUseBlockToPart(block: { + id: string; + name: string; + input: unknown; +}): Part { + return { + text: `[Anthropic server tool ${block.name}] input: ${JSON.stringify(block.input)}`, + metadata: { + anthropicServerToolUse: { + id: block.id, + name: block.name, + input: block.input, + }, + }, + }; +} diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 1496029ebd..61c921b97c 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -44,6 +44,17 @@ import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { removeUndefinedProperties } from '../utils.js'; import { BaseRunner } from './base.js'; +import { + inputJsonDeltaError, + redactedThinkingBlockToPart, + textBlockToPart, + textDeltaToPart, + thinkingBlockToPart, + thinkingDeltaToPart, + toolUseBlockToPart, + webSearchToolResultBlockToPart, +} from './converters/shared.js'; +import { serverToolUseBlockToPart } from './converters/stable.js'; import { RunnerTypes as BaseRunnerTypes } from './types.js'; interface RunnerTypes extends BaseRunnerTypes { @@ -84,7 +95,7 @@ export class Runner extends BaseRunner { const signature = this.getThinkingSignature(part); if (!signature) { throw new Error( - 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `custom.anthropicThinking.signature` value from the original response.' + 'Anthropic thinking parts require a signature when sending back to the API. Preserve the `metadata.thoughtSignature` value from the original response.' ); } return { @@ -336,18 +347,16 @@ export class Runner extends BaseRunner { 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 }; + return textDeltaToPart(delta); } if (delta.type === 'thinking_delta') { - return { reasoning: delta.thinking }; + return thinkingDeltaToPart(delta); + } + + if (delta.type === 'input_json_delta') { + throw inputJsonDeltaError(); } // signature_delta - ignore @@ -359,42 +368,23 @@ export class Runner extends BaseRunner { 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 }; + return textBlockToPart(block); + + case 'tool_use': + return toolUseBlockToPart(block); case 'thinking': - return this.createThinkingPart(block.thinking, block.signature); + return thinkingBlockToPart(block); case 'redacted_thinking': - return { custom: { redactedThinking: block.data } }; + return redactedThinkingBlockToPart(block); - case 'tool_use': - return { - toolRequest: { - ref: block.id, - name: block.name, - input: block.input, - }, - }; + case 'server_tool_use': + return serverToolUseBlockToPart(block); + + case 'web_search_tool_result': + return webSearchToolResultBlockToPart(block); default: { const unknownType = (block as { type: string }).type; @@ -412,47 +402,30 @@ export class Runner extends BaseRunner { 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 'text': + return textBlockToPart(contentBlock); case 'tool_use': - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }; - - case 'text': - return { text: contentBlock.text }; + return toolUseBlockToPart(contentBlock); case 'thinking': - return this.createThinkingPart( - contentBlock.thinking, - contentBlock.signature - ); + return thinkingBlockToPart(contentBlock); case 'redacted_thinking': - return { custom: { redactedThinking: contentBlock.data } }; + return redactedThinkingBlockToPart(contentBlock); + + case 'server_tool_use': + return serverToolUseBlockToPart(contentBlock); + + case 'web_search_tool_result': + return webSearchToolResultBlockToPart(contentBlock); default: { + // Exhaustive check (uncomment when all types are handled): + // const _exhaustive: never = contentBlock; + // throw new Error( + // `Unhandled block type: ${(_exhaustive as { type: string }).type}` + // ); const unknownType = (contentBlock as { type: string }).type; logger.warn( `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 0d549b938c..98e4226e86 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -321,7 +321,7 @@ describe('BetaRunner', () => { const toolPart = exposed.toGenkitPart(serverToolEvent); assert.deepStrictEqual(toolPart, { text: '[Anthropic server tool srv/myTool] input: {"foo":"bar"}', - custom: { + metadata: { anthropicServerToolUse: { id: 'toolu_test', name: 'srv/myTool', @@ -732,7 +732,7 @@ describe('BetaRunner', () => { }); assert.deepStrictEqual(thinkingPart, { reasoning: 'pondering', - custom: { anthropicThinking: { signature: 'sig_456' } }, + metadata: { thoughtSignature: 'sig_456' }, }); const redactedPart = (runner as any).fromBetaContentBlock({ @@ -766,7 +766,7 @@ describe('BetaRunner', () => { }); assert.deepStrictEqual(serverToolPart, { text: '[Anthropic server tool srv/serverTool] input: {"arg":"value"}', - custom: { + metadata: { anthropicServerToolUse: { id: 'srv_tool_1', name: 'srv/serverTool', diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index 209a455870..13d88b373f 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -345,7 +345,7 @@ describe('Anthropic Integration', () => { ); assert.ok(reasoningPart, 'Expected reasoning part in assistant message'); assert.strictEqual( - reasoningPart?.custom?.anthropicThinking?.signature, + (reasoningPart?.metadata as Record)?.thoughtSignature, 'sig_reasoning_123' ); }); diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 72797251b9..28e1834e2a 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -684,7 +684,7 @@ describe('fromAnthropicContentBlockChunk', () => { }, expectedOutput: { reasoning: 'Let me reason through this.', - custom: { anthropicThinking: { signature: 'sig_123' } }, + metadata: { thoughtSignature: 'sig_123' }, }, }, {