From 6a574ec21439a12220f486dadf8de988b447dc8e Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 27 Oct 2025 11:44:22 +0000 Subject: [PATCH 01/51] feat(js/plugins/anthropic): add community plugin as starter --- js/plugins/anthropic/.gitignore | 3 + js/plugins/anthropic/.npmignore | 17 + js/plugins/anthropic/README.md | 126 +++ js/plugins/anthropic/jest.config.js | 6 + js/plugins/anthropic/package.json | 67 ++ js/plugins/anthropic/src/claude.test.ts | 1027 +++++++++++++++++++++++ js/plugins/anthropic/src/claude.ts | 638 ++++++++++++++ js/plugins/anthropic/src/index.ts | 144 ++++ js/plugins/anthropic/tsconfig.json | 4 + js/plugins/anthropic/tsup.config.ts | 24 + 10 files changed, 2056 insertions(+) create mode 100644 js/plugins/anthropic/.gitignore create mode 100644 js/plugins/anthropic/.npmignore create mode 100644 js/plugins/anthropic/README.md create mode 100644 js/plugins/anthropic/jest.config.js create mode 100644 js/plugins/anthropic/package.json create mode 100644 js/plugins/anthropic/src/claude.test.ts create mode 100644 js/plugins/anthropic/src/claude.ts create mode 100644 js/plugins/anthropic/src/index.ts create mode 100644 js/plugins/anthropic/tsconfig.json create mode 100644 js/plugins/anthropic/tsup.config.ts diff --git a/js/plugins/anthropic/.gitignore b/js/plugins/anthropic/.gitignore new file mode 100644 index 0000000000..d83aca04ae --- /dev/null +++ b/js/plugins/anthropic/.gitignore @@ -0,0 +1,3 @@ +lib/ +node_modules/ +coverage/ diff --git a/js/plugins/anthropic/.npmignore b/js/plugins/anthropic/.npmignore new file mode 100644 index 0000000000..d265d4ab73 --- /dev/null +++ b/js/plugins/anthropic/.npmignore @@ -0,0 +1,17 @@ +# typescript source files +src/ +tests/ +tsconfig.json +tsup.common.ts +tsup.config.ts + +# GitHub files +.github/ +.gitignore +.npmignore +CODE_OF_CONDUCT.md +CONTRIBUTING.md + +# Developer related files +.devcontainer/ +.vscode/ diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md new file mode 100644 index 0000000000..4cf27eae0d --- /dev/null +++ b/js/plugins/anthropic/README.md @@ -0,0 +1,126 @@ +![Firebase Genkit + Anthropic AI](https://github.com/BloomLabsInc/genkit-plugins/blob/main/assets/genkit-anthropic.png?raw=true) + +

Firebase Genkit <> Anthropic AI Plugin

+ +

Anthropic AI Community Plugin for Google Firebase Genkit

+ +
+ Github lerna version + NPM Downloads + GitHub Org's stars + GitHub License + Static Badge +
+ +
+ GitHub Issues or Pull Requests + GitHub Issues or Pull Requests + GitHub commit activity +
+ +`genkitx-anthropic` is a community plugin for using Anthropic AI and all its supported models with [Firebase Genkit](https://github.com/firebase/genkit). + +This Genkit plugin allows to use Anthropic AI models through their official APIs. + +If you want to use Anthropic AI models through Google Vertex AI, please refer +to the [official Vertex AI plugin](https://www.npmjs.com/package/@genkit-ai/vertexai). + +## Supported models + +The plugin supports the most recent Anthropic models: +**Claude 3.7 Sonnet**, **Claude 3.5 Sonnet**, **Claude 3 Opus**, **Claude 3 Sonnet**, and **Claude 3 Haiku**. + +## Installation + +Install the plugin in your project with your favorite package manager: + +- `npm install genkitx-anthropic` +- `yarn add genkitx-anthropic` + +## Usage + +### Initialize + +```typescript +import { genkit } from 'genkit'; +import { anthropic, claude35Sonnet } from 'genkitx-anthropic'; + +const ai = genkit({ + plugins: [anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })], + // specify a default model for generate here if you wish: + model: claude35Sonnet, +}); +``` + +### Basic examples + +The simplest way to generate text is by using the `generate` method: + +```typescript +const response = await ai.generate({ + model: claude3Haiku, // model imported from genkitx-anthropic + prompt: 'Tell me a joke.', +}); + +console.log(response.text); +``` + +### Multi-modal prompt + +```typescript +// ...intialize Genkit instance (as shown above)... + +const response = await ai.generate({ + prompt: [ + { text: 'What animal is in the photo?' }, + { media: { url: imageUrl } }, + ], + config: { + // control of the level of visual detail when processing image embeddings + // Low detail level also decreases the token usage + visualDetailLevel: 'low', + }, +}); +console.log(response.text); +``` + +### Within a flow + +```typescript +import { z } from 'genkit'; + +// ...initialize Genkit instance (as shown above)... + +export const jokeFlow = ai.defineFlow( + { + name: 'jokeFlow', + inputSchema: z.string(), + outputSchema: z.string(), + }, + async (subject) => { + const llmResponse = await ai.generate({ + prompt: `tell me a joke about ${subject}`, + }); + return llmResponse.text; + } +); +``` + +## Contributing + +Want to contribute to the project? That's awesome! Head over to our [Contribution Guidelines](CONTRIBUTING.md). + +## Need support? + +> \[!NOTE\]\ +> This repository depends on Google's Firebase Genkit. For issues and questions related to Genkit, please refer to instructions available in [Genkit's repository](https://github.com/firebase/genkit). + +Reach out by opening a discussion on [Github Discussions](https://github.com/BloomLabsInc/genkitx-openai/discussions). + +## Credits + +This plugin is proudly maintained by the team at [**Bloom Labs Inc**](https://github.com/BloomLabsInc). 🔥 + +## License + +This project is licensed under the [Apache 2.0 License](https://github.com/BloomLabsInc/genkit-plugins/blob/main/LICENSE). diff --git a/js/plugins/anthropic/jest.config.js b/js/plugins/anthropic/jest.config.js new file mode 100644 index 0000000000..d0fe51d60c --- /dev/null +++ b/js/plugins/anthropic/jest.config.js @@ -0,0 +1,6 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/lib/'], +}; diff --git a/js/plugins/anthropic/package.json b/js/plugins/anthropic/package.json new file mode 100644 index 0000000000..b45c188ad5 --- /dev/null +++ b/js/plugins/anthropic/package.json @@ -0,0 +1,67 @@ +{ + "name": "genkitx-anthropic", + "description": "Firebase Genkit AI framework plugin for Anthropic APIs.", + "keywords": [ + "genkit", + "genkit-plugin", + "genkit-model", + "anthropic", + "anthropic-ai", + "claude-4", + "haiku-4", + "opus", + "haiku", + "sonnet", + "ai", + "genai", + "generative-ai" + ], + "version": "0.25.0", + "type": "commonjs", + "repository": { + "type": "git", + "url": "git+https://github.com/BloomLabsInc/genkit-plugins.git", + "directory": "plugins/anthropic" + }, + "author": "BloomLabsInc", + "license": "Apache-2.0", + "peerDependencies": { + "genkit": "^1.19.3" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0" + }, + "devDependencies": { + "@jest/globals": "^29.7.0", + "@types/node": "^20.12.12", + "jest": "^29.7.0", + "npm-run-all": "^4.1.5", + "ts-jest": "^29.1.2", + "tsup": "^8.0.2", + "typescript": "^5.4.5" + }, + "types": "./lib/index.d.ts", + "exports": { + ".": { + "require": "./lib/index.js", + "import": "./lib/index.mjs", + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + } + }, + "files": [ + "lib" + ], + "publishConfig": { + "provenance": true, + "access": "public" + }, + "scripts": { + "check": "tsc", + "compile": "tsup-node", + "build:clean": "rm -rf ./lib", + "build": "npm-run-all build:clean check compile", + "build:watch": "tsup-node --watch", + "test": "jest --coverage" + } +} diff --git a/js/plugins/anthropic/src/claude.test.ts b/js/plugins/anthropic/src/claude.test.ts new file mode 100644 index 0000000000..b72e42fbf4 --- /dev/null +++ b/js/plugins/anthropic/src/claude.test.ts @@ -0,0 +1,1027 @@ +/** + * Copyright 2024 Bloom Labs Inc + * + * 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 { + describe, + it, + expect, + beforeEach, + afterEach, + jest, +} from '@jest/globals'; +import type { + GenerateResponseData, + Part, + GenerateRequest, + MessageData, + Role, + Genkit, +} from 'genkit'; +import type { + Message, + MessageParam, + MessageCreateParams, + MessageStreamEvent, +} from '@anthropic-ai/sdk/resources/messages.mjs'; +import type Anthropic from '@anthropic-ai/sdk'; +import type { CandidateData, ToolDefinition } from 'genkit/model'; + +import type { AnthropicConfigSchema } from './claude'; +import { + claude35Haiku, + claudeModel, + claudeRunner, + fromAnthropicContentBlockChunk, + fromAnthropicResponse, + fromAnthropicStopReason, + toAnthropicMessageContent, + toAnthropicMessages, + toAnthropicRequestBody, + toAnthropicRole, + toAnthropicTool, + toAnthropicToolResponseContent, +} from './claude'; + +jest.mock('genkit/plugin', () => ({ + ...(jest.requireActual('genkit/plugin') as object), + model: jest.fn(() => ({})), +})); + +describe('toAnthropicRole', () => { + const testCases: { + genkitRole: Role; + toolMessageType?: 'tool_use' | 'tool_result'; + expectedAnthropicRole: MessageParam['role']; + }[] = [ + { + genkitRole: 'user', + expectedAnthropicRole: 'user', + }, + { + genkitRole: 'model', + expectedAnthropicRole: 'assistant', + }, + { + genkitRole: 'tool', + toolMessageType: 'tool_use', + expectedAnthropicRole: 'assistant', + }, + { + genkitRole: 'tool', + toolMessageType: 'tool_result', + expectedAnthropicRole: 'user', + }, + ]; + + for (const test of testCases) { + it(`should map Genkit "${test.genkitRole}" role to Anthropic "${test.expectedAnthropicRole}" role${ + test.toolMessageType + ? ` when toolMessageType is "${test.toolMessageType}"` + : '' + }`, () => { + const actualOutput = toAnthropicRole( + test.genkitRole, + test.toolMessageType + ); + expect(actualOutput).toBe(test.expectedAnthropicRole); + }); + } + + it('should throw an error for unknown roles', () => { + expect(() => toAnthropicRole('unknown' as Role)).toThrowError( + "role unknown doesn't map to an Anthropic role." + ); + }); +}); + +describe('toAnthropicToolResponseContent', () => { + it('should throw an error for unknown parts', () => { + const part: Part = { data: 'hi' } as Part; + expect(() => toAnthropicToolResponseContent(part)).toThrowError( + `Invalid genkit part provided to toAnthropicToolResponseContent: {"data":"hi"}` + ); + }); +}); + +describe('toAnthropicMessageContent', () => { + it('should throw if a media part contains invalid media', () => { + expect(() => + toAnthropicMessageContent({ + media: { + url: '', + }, + }) + ).toThrowError( + 'Invalid genkit part media provided to toAnthropicMessageContent: {"url":""}' + ); + }); + + it('should throw if the provided part is invalid', () => { + expect(() => + toAnthropicMessageContent({ fake: 'part' } as Part) + ).toThrowError( + 'Unsupported genkit part fields encountered for current message role: {"fake":"part"}' + ); + }); +}); + +describe('toAnthropicMessages', () => { + const testCases: { + should: string; + inputMessages: MessageData[]; + expectedOutput: { + messages: MessageParam[]; + system?: string; + }; + }[] = [ + { + should: 'should transform tool request content correctly', + inputMessages: [ + { + role: 'model', + content: [ + { + toolRequest: { + ref: 'toolu_01A09q90qw90lq917835lq9', + name: 'tellAFunnyJoke', + input: { topic: 'bob' }, + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'toolu_01A09q90qw90lq917835lq9', + name: 'tellAFunnyJoke', + input: { topic: 'bob' }, + }, + ], + }, + ], + system: undefined, + }, + }, + { + should: 'should transform tool response text content correctly', + inputMessages: [ + { + role: 'tool', + content: [ + { + toolResponse: { + ref: 'call_SVDpFV2l2fW88QRFtv85FWwM', + name: 'tellAFunnyJoke', + output: 'Why did the bob cross the road?', + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_SVDpFV2l2fW88QRFtv85FWwM', + content: [ + { + type: 'text', + text: 'Why did the bob cross the road?', + }, + ], + }, + ], + }, + ], + system: undefined, + }, + }, + { + should: 'should transform tool response media content correctly', + inputMessages: [ + { + role: 'tool', + content: [ + { + toolResponse: { + ref: 'call_SVDpFV2l2fW88QRFtv85FWwM', + name: 'tellAFunnyJoke', + output: { + url: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', + contentType: 'image/gif', + }, + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_SVDpFV2l2fW88QRFtv85FWwM', + content: [ + { + type: 'image', + source: { + type: 'base64', + data: 'R0lGODlhAQABAAAAACw=', + media_type: 'image/gif', + }, + }, + ], + }, + ], + }, + ], + system: undefined, + }, + }, + { + should: + 'should transform tool response base64 image url content correctly', + inputMessages: [ + { + role: 'tool', + content: [ + { + toolResponse: { + ref: 'call_SVDpFV2l2fW88QRFtv85FWwM', + name: 'tellAFunnyJoke', + output: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call_SVDpFV2l2fW88QRFtv85FWwM', + content: [ + { + type: 'image', + source: { + type: 'base64', + data: 'R0lGODlhAQABAAAAACw=', + media_type: 'image/gif', + }, + }, + ], + }, + ], + }, + ], + system: undefined, + }, + }, + { + should: 'should transform text content correctly', + inputMessages: [ + { role: 'user', content: [{ text: 'hi' }] }, + { role: 'model', content: [{ text: 'how can I help you?' }] }, + { role: 'user', content: [{ text: 'I am testing' }] }, + ], + expectedOutput: { + messages: [ + { + content: [ + { + text: 'hi', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + { + content: [ + { + text: 'how can I help you?', + type: 'text', + citations: null, + }, + ], + role: 'assistant', + }, + { + content: [ + { + text: 'I am testing', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + ], + system: undefined, + }, + }, + { + should: 'should transform initial system prompt correctly', + inputMessages: [ + { role: 'system', content: [{ text: 'You are an helpful assistant' }] }, + { role: 'user', content: [{ text: 'hi' }] }, + ], + expectedOutput: { + messages: [ + { + content: [ + { + text: 'hi', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + ], + system: 'You are an helpful assistant', + }, + }, + { + should: 'should transform multi-modal (text + media) content correctly', + inputMessages: [ + { + role: 'user', + content: [ + { text: 'describe the following image:' }, + { + media: { + url: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', + contentType: 'image/gif', + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + content: [ + { + text: 'describe the following image:', + type: 'text', + citations: null, + }, + { + source: { + type: 'base64', + data: 'R0lGODlhAQABAAAAACw=', + media_type: 'image/gif', + }, + type: 'image', + }, + ], + role: 'user', + }, + ], + system: undefined, + }, + }, + ]; + + for (const test of testCases) { + it(test.should, () => { + const actualOutput = toAnthropicMessages(test.inputMessages); + expect(actualOutput).toStrictEqual(test.expectedOutput); + }); + } +}); + +describe('toAnthropicTool', () => { + it('should transform Genkit tool definition to an Anthropic tool', () => { + const tool: ToolDefinition = { + name: 'tellAJoke', + description: 'Tell a joke', + inputSchema: { + type: 'object', + properties: { + topic: { type: 'string' }, + }, + required: ['topic'], + }, + }; + const actualOutput = toAnthropicTool(tool); + expect(actualOutput).toStrictEqual({ + name: 'tellAJoke', + description: 'Tell a joke', + input_schema: { + type: 'object', + properties: { + topic: { type: 'string' }, + }, + required: ['topic'], + }, + }); + }); +}); + +describe('fromAnthropicContentBlockChunk', () => { + const testCases: { + should: string; + event: MessageStreamEvent; + expectedOutput: Part | undefined; + }[] = [ + { + should: 'should return text part from content_block_start event', + event: { + index: 0, + type: 'content_block_start', + content_block: { + type: 'text', + text: 'Hello, World!', + citations: null, + }, + }, + expectedOutput: { text: 'Hello, World!' }, + }, + { + should: 'should return text delta part from content_block_delta event', + event: { + index: 0, + type: 'content_block_delta', + delta: { + type: 'text_delta', + text: 'Hello, World!', + }, + }, + expectedOutput: { text: 'Hello, World!' }, + }, + { + should: 'should return tool use requests', + event: { + index: 0, + type: 'content_block_start', + content_block: { + type: 'tool_use', + id: 'abc123', + name: 'tellAJoke', + input: { topic: 'dogs' }, + }, + }, + expectedOutput: { + toolRequest: { + name: 'tellAJoke', + input: { topic: 'dogs' }, + ref: 'abc123', + }, + }, + }, + { + should: 'should return undefined for any other event', + event: { + type: 'message_stop', + }, + expectedOutput: undefined, + }, + ]; + + for (const test of testCases) { + it(test.should, () => { + const actualOutput = fromAnthropicContentBlockChunk(test.event); + expect(actualOutput).toStrictEqual(test.expectedOutput); + }); + } +}); + +describe('fromAnthropicStopReason', () => { + const testCases: { + inputStopReason: Message['stop_reason']; + expectedFinishReason: CandidateData['finishReason']; + }[] = [ + { + inputStopReason: 'max_tokens', + expectedFinishReason: 'length', + }, + { + inputStopReason: 'end_turn', + expectedFinishReason: 'stop', + }, + { + inputStopReason: 'stop_sequence', + expectedFinishReason: 'stop', + }, + { + inputStopReason: 'tool_use', + expectedFinishReason: 'stop', + }, + { + inputStopReason: null, + expectedFinishReason: 'unknown', + }, + { + inputStopReason: 'unknown' as any, + expectedFinishReason: 'other', + }, + ]; + + for (const test of testCases) { + it(`should map Anthropic stop reason "${test.inputStopReason}" to Genkit finish reason "${test.expectedFinishReason}"`, () => { + const actualOutput = fromAnthropicStopReason(test.inputStopReason); + expect(actualOutput).toBe(test.expectedFinishReason); + }); + } +}); + +describe('fromAnthropicResponse', () => { + const testCases: { + should: string; + message: Message; + expectedOutput: GenerateResponseData; + }[] = [ + { + should: 'should work with text content', + message: { + id: 'abc123', + model: 'whatever', + type: 'message', + role: 'assistant', + stop_reason: 'max_tokens', + stop_sequence: null, + content: [ + { + type: 'text', + text: 'Tell a joke about dogs.', + citations: null, + }, + ], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }, + }, + expectedOutput: { + candidates: [ + { + index: 0, + finishReason: 'length', + message: { + role: 'model', + content: [{ text: 'Tell a joke about dogs.' }], + }, + }, + ], + usage: { + inputTokens: 10, + outputTokens: 20, + }, + custom: expect.any(Object), + }, + }, + { + should: 'should work with tool use content', + message: { + id: 'abc123', + model: 'whatever', + type: 'message', + role: 'assistant', + stop_reason: 'tool_use', + stop_sequence: null, + content: [ + { + type: 'tool_use', + id: 'abc123', + name: 'tellAJoke', + input: { topic: 'dogs' }, + }, + ], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + }, + }, + expectedOutput: { + candidates: [ + { + index: 0, + finishReason: 'stop', + message: { + role: 'model', + content: [ + { + toolRequest: { + name: 'tellAJoke', + input: { topic: 'dogs' }, + ref: 'abc123', + }, + }, + ], + }, + }, + ], + usage: { + inputTokens: 10, + outputTokens: 20, + }, + custom: expect.any(Object), + }, + }, + ]; + + for (const test of testCases) { + it(test.should, () => { + const actualOutput = fromAnthropicResponse(test.message); + expect(actualOutput).toStrictEqual(test.expectedOutput); + }); + } +}); + +describe('toAnthropicRequestBody', () => { + const testCases: { + should: string; + modelName: string; + genkitRequest: GenerateRequest; + expectedOutput: MessageCreateParams; + }[] = [ + { + should: '(claude-3-5-sonnet) handles request with text messages', + modelName: 'claude-3-5-sonnet', + genkitRequest: { + messages: [ + { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, + ], + output: { format: 'text' }, + config: { + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + expectedOutput: { + max_tokens: 4096, + messages: [ + { + content: [ + { + text: 'Tell a joke about dogs.', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + ], + model: 'claude-3-5-sonnet-latest', + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + { + should: '(claude-3-5-haiku) handles request with text messages', + modelName: 'claude-3-5-haiku', + genkitRequest: { + messages: [ + { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, + ], + output: { format: 'text' }, + config: { + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + expectedOutput: { + max_tokens: 4096, + messages: [ + { + content: [ + { + text: 'Tell a joke about dogs.', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + ], + model: 'claude-3-5-haiku-latest', + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + { + should: '(claude-3-opus) handles request with text messages', + modelName: 'claude-3-opus', + genkitRequest: { + messages: [ + { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, + ], + output: { format: 'text' }, + config: { + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + expectedOutput: { + max_tokens: 4096, + messages: [ + { + content: [ + { + text: 'Tell a joke about dogs.', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + ], + model: 'claude-3-opus-20240229', + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + { + should: '(claude-3-haiku) handles request with text messages', + modelName: 'claude-3-haiku', + genkitRequest: { + messages: [ + { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, + ], + output: { format: 'text' }, + config: { + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + expectedOutput: { + max_tokens: 4096, + messages: [ + { + content: [ + { + text: 'Tell a joke about dogs.', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + ], + model: 'claude-3-haiku-20240307', + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + { + should: '(claude-3-sonnet) handles request with text messages', + modelName: 'claude-3-sonnet', + genkitRequest: { + messages: [ + { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, + ], + output: { format: 'text' }, + config: { + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + expectedOutput: { + max_tokens: 4096, + messages: [ + { + content: [ + { + text: 'Tell a joke about dogs.', + type: 'text', + citations: null, + }, + ], + role: 'user', + }, + ], + model: 'claude-3-sonnet-20240229', + metadata: { + user_id: 'exampleUser123', + }, + }, + }, + ]; + for (const test of testCases) { + it(test.should, () => { + const actualOutput = toAnthropicRequestBody( + test.modelName, + test.genkitRequest + ); + expect(actualOutput).toStrictEqual(test.expectedOutput); + }); + } + + it('should throw if model is not supported', () => { + expect(() => + toAnthropicRequestBody('fake-model', { + messages: [], + } as GenerateRequest) + ).toThrowError('Unsupported model: fake-model'); + }); + + it('should throw if output format is not text', () => { + expect(() => + toAnthropicRequestBody('claude-3-5-haiku', { + messages: [], + tools: [], + output: { format: 'media' }, + } as GenerateRequest) + ).toThrowError( + 'Only text output format is supported for Claude models currently' + ); + }); + + it('should apply system prompt caching when enabled', () => { + const request: GenerateRequest = { + messages: [ + { role: 'system', content: [{ text: 'You are a helpful assistant' }] }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + }; + + // Test with caching enabled + const outputWithCaching = toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false, + true + ); + expect(outputWithCaching.system).toEqual([ + { + type: 'text', + text: 'You are a helpful assistant', + cache_control: { type: 'ephemeral' }, + }, + ]); + + // Test with caching disabled + const outputWithoutCaching = toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false, + false + ); + expect(outputWithoutCaching.system).toBe('You are a helpful assistant'); + }); +}); + +describe('claudeRunner', () => { + it('should correctly run non-streaming requests', async () => { + const anthropicClient = { + messages: { + create: jest.fn(async () => ({ + content: [{ type: 'text', text: 'response' }], + usage: { + input_tokens: 10, + output_tokens: 20, + }, + })), + }, + }; + const runner = claudeRunner( + 'claude-3-5-haiku', + anthropicClient as unknown as Anthropic + ); + const abortSignal = new AbortController().signal; + await runner( + { messages: [] }, + { streamingRequested: false, sendChunk: () => {}, abortSignal } + ); + expect(anthropicClient.messages.create).toHaveBeenCalledWith( + { + model: 'claude-3-5-haiku-latest', + max_tokens: 4096, + }, + { + signal: abortSignal, + } + ); + }); + + it('should correctly run streaming requests', async () => { + const anthropicClient = { + messages: { + stream: jest.fn( + () => + // Simulate Anthropic SDK request streaming + new (class { + isFirstRequest = true; + [Symbol.asyncIterator]() { + return { + next: async () => { + const returnValue = this.isFirstRequest + ? { + value: { + type: 'content_block_start', + content_block: { + type: 'text', + text: 'res', + }, + }, + done: false, + } + : { done: true }; + this.isFirstRequest = false; + return returnValue; + }, + }; + } + async finalMessage() { + return { + content: [{ type: 'text', text: 'response' }], + usage: { + input_tokens: 10, + output_tokens: 20, + }, + }; + } + })() + ), + }, + }; + const streamingCallback = jest.fn(); + const runner = claudeRunner( + 'claude-3-5-haiku', + anthropicClient as unknown as Anthropic + ); + const abortSignal = new AbortController().signal; + await runner( + { messages: [] }, + { streamingRequested: true, sendChunk: streamingCallback, abortSignal } + ); + expect(anthropicClient.messages.stream).toHaveBeenCalledWith( + { + model: 'claude-3-5-haiku-latest', + max_tokens: 4096, + stream: true, + }, + { + signal: abortSignal, + } + ); + }); +}); + +describe('claudeModel', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should correctly define supported Claude models', () => { + const { model } = jest.requireMock('genkit/plugin') as { model: jest.Mock }; + const modelName = 'claude-3-5-haiku'; + claudeModel(modelName, {} as Anthropic); + expect(model).toHaveBeenCalledWith( + { + name: `anthropic/${modelName}`, + ...claude35Haiku.info, + configSchema: claude35Haiku.configSchema, + }, + expect.any(Function) + ); + }); + + it('should throw for unsupported models', () => { + expect(() => + claudeModel('unsupported-model', {} as Anthropic) + ).toThrowError('Unsupported model: unsupported-model'); + }); +}); diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts new file mode 100644 index 0000000000..cef7355abe --- /dev/null +++ b/js/plugins/anthropic/src/claude.ts @@ -0,0 +1,638 @@ +/** + * Copyright 2024 Bloom Labs Inc + * + * 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 { Message as GenkitMessage, z } from 'genkit'; +import type { + GenerateResponseData, + GenerateRequest, + MessageData, + Part, + Role, + StreamingCallback, + ModelReference, +} from 'genkit'; +import { GenerationCommonConfigSchema } from 'genkit'; +import type { + CandidateData, + GenerateResponseChunkData, + ModelAction, + ToolDefinition, +} from 'genkit/model'; +import { modelRef } from 'genkit/model'; +import type Anthropic from '@anthropic-ai/sdk'; +import type { + ImageBlockParam, + TextBlock, + TextBlockParam, + MessageCreateParams, + Tool, + ToolResultBlockParam, + ContentBlock, + Message, + MessageParam, + MessageStreamEvent, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/messages.mjs'; +import { model } from 'genkit/plugin'; + +export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ + tool_choice: z + .union([ + z.object({ + type: z.literal('auto'), + }), + z.object({ + type: z.literal('any'), + }), + z.object({ + type: z.literal('tool'), + name: z.string(), + }), + ]) + .optional(), + metadata: z + .object({ + user_id: z.string().optional(), + }) + .optional(), +}); + +export const claude4Sonnet = modelRef({ + name: 'claude-4-sonnet', + namespace: 'anthropic', + info: { + versions: ['claude-sonnet-4-20250514'], + label: 'Anthropic - Claude 4 Sonnet', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-sonnet-4-20250514', +}); + +export const claude37Sonnet = modelRef({ + name: 'claude-3-7-sonnet', + namespace: 'anthropic', + info: { + versions: ['claude-3-7-sonnet-20250219', 'claude-3-7-sonnet-latest'], + label: 'Anthropic - Claude 3.7 Sonnet', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-7-sonnet-latest', +}); + +export const claude35Sonnet = modelRef({ + name: 'claude-3-5-sonnet', + namespace: 'anthropic', + info: { + versions: [ + 'claude-3-5-sonnet-20240620', + 'claude-3-5-sonnet-20241022', + 'claude-3-5-sonnet-latest', + ], + label: 'Anthropic - Claude 3.5 Sonnet', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-5-sonnet-latest', +}); + +export const claude3Opus = modelRef({ + name: 'claude-3-opus', + namespace: 'anthropic', + info: { + versions: ['claude-3-opus-20240229'], + label: 'Anthropic - Claude 3 Opus', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-opus-20240229', +}); + +export const claude3Sonnet = modelRef({ + name: 'claude-3-sonnet', + namespace: 'anthropic', + info: { + versions: ['claude-3-sonnet-20240229'], + label: 'Anthropic - Claude 3 Sonnet', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-sonnet-20240229', +}); + +export const claude3Haiku = modelRef({ + name: 'claude-3-haiku', + namespace: 'anthropic', + info: { + versions: ['claude-3-haiku-20240307'], + label: 'Anthropic - Claude 3 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-haiku-20240307', +}); + +export const claude4Opus = modelRef({ + name: 'claude-4-opus', + namespace: 'anthropic', + info: { + versions: ['claude-opus-4-20250514'], + label: 'Anthropic - Claude 4 Opus', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-opus-4-20250514', +}); + +export const claude35Haiku = modelRef({ + name: 'claude-3-5-haiku', + namespace: 'anthropic', + info: { + versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku-latest'], + label: 'Anthropic - Claude 3.5 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-5-haiku-latest', +}); + +export const SUPPORTED_CLAUDE_MODELS: Record< + string, + ModelReference +> = { + 'claude-3-7-sonnet': claude37Sonnet, + 'claude-3-5-sonnet': claude35Sonnet, + 'claude-3-opus': claude3Opus, + 'claude-3-sonnet': claude3Sonnet, + 'claude-3-haiku': claude3Haiku, + 'claude-3-5-haiku': claude35Haiku, + 'claude-4-sonnet': claude4Sonnet, + 'claude-4-opus': claude4Opus, +}; + +/** + * Converts a Genkit role to the corresponding Anthropic role. + */ +export function toAnthropicRole( + role: Role, + toolMessageType?: 'tool_use' | 'tool_result' +): MessageParam['role'] { + switch (role) { + case 'user': + return 'user'; + case 'model': + return 'assistant'; + case 'tool': + return toolMessageType === 'tool_use' ? 'assistant' : 'user'; + default: + throw new Error(`role ${role} doesn't map to an Anthropic role.`); + } +} + +interface Media { + url: string; + contentType?: string; +} + +const isMediaObject = (obj: unknown): obj is Media => + typeof obj === 'object' && + obj !== null && + 'url' in obj && + typeof (obj as Media).url === 'string'; + +const extractDataFromBase64Url = ( + url: string +): { data: string; contentType: string } | null => { + const match = url.match(/^data:([^;]+);base64,(.+)$/); + return ( + match && { + contentType: match[1], + data: match[2], + } + ); +}; + +/** + * Converts a Genkit message Part to the corresponding Anthropic TextBlockParam or ImageBlockParam. + */ +export function toAnthropicToolResponseContent( + part: Part +): TextBlockParam | ImageBlockParam { + if (!part.toolResponse) { + throw Error( + `Invalid genkit part provided to toAnthropicToolResponseContent: ${JSON.stringify( + part + )}.` + ); + } + const isMedia = isMediaObject(part.toolResponse?.output); + const isString = typeof part.toolResponse?.output === 'string'; + let base64Data; + if (isMedia) { + base64Data = extractDataFromBase64Url( + (part.toolResponse?.output as Media).url + ); + } else if (isString) { + base64Data = extractDataFromBase64Url(part.toolResponse?.output as string); + } + return base64Data + ? { + type: 'image', + source: { + type: 'base64', + data: base64Data.data, + media_type: + (part.toolResponse?.output as Media)?.contentType ?? + base64Data.contentType, + }, + } + : { + type: 'text', + text: isString + ? (part.toolResponse?.output as string) + : JSON.stringify(part.toolResponse?.output), + }; +} + +/** + * Converts a Genkit Part to the corresponding Anthropic TextBlock, ImageBlockParam, etc. + */ +export function toAnthropicMessageContent( + part: Part +): TextBlock | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam { + if (part.text) { + return { + type: 'text', + text: part.text, + citations: null, + }; + } + if (part.media) { + const { data, contentType } = + extractDataFromBase64Url(part.media.url) ?? {}; + if (!data) { + throw Error( + `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( + part.media + )}.` + ); + } + return { + type: 'image', + source: { + type: 'base64', + data, + // @ts-expect-error TODO: improve these types + media_type: part.media.contentType ?? contentType, + }, + }; + } + if (part.toolRequest) { + return { + type: 'tool_use', + id: part.toolRequest.ref!, + name: part.toolRequest.name, + input: part.toolRequest.input, + }; + } + if (part.toolResponse) { + return { + type: 'tool_result', + tool_use_id: part.toolResponse.ref!, + content: [toAnthropicToolResponseContent(part)], + }; + } + throw Error( + `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( + part + )}.` + ); +} + +/** + * Converts a Genkit MessageData array to Anthropic system message and MessageParam array. + * @param messages The Genkit MessageData array to convert. + * @returns An object containing the optional Anthropic system message and the array of Anthropic MessageParam objects. + */ +export function toAnthropicMessages(messages: MessageData[]): { + system?: string; + messages: MessageParam[]; +} { + const system = + messages[0]?.role === 'system' ? messages[0].content?.[0]?.text : undefined; + const messagesToIterate = system ? messages.slice(1) : messages; + const anthropicMsgs: MessageParam[] = []; + for (const message of messagesToIterate) { + const msg = new GenkitMessage(message); + const content = msg.content.map(toAnthropicMessageContent); + const toolMessageType = content.find( + (c) => c.type === 'tool_use' || c.type === 'tool_result' + ) as ToolUseBlockParam | ToolResultBlockParam; + const role = toAnthropicRole(message.role, toolMessageType?.type); + anthropicMsgs.push({ + role: role, + content, + }); + } + return { system, messages: anthropicMsgs }; +} + +/** + * Converts a Genkit ToolDefinition to an Anthropic Tool object. + * @param tool The Genkit ToolDefinition to convert. + * @returns The converted Anthropic Tool object. + */ +export function toAnthropicTool(tool: ToolDefinition): Tool { + return { + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema as Tool.InputSchema, + }; +} + +/** + * Converts an Anthropic content block to a Genkit Part object. + * @param contentBlock The Anthropic content block to convert. + * @returns The converted Genkit Part object. + * @param event The Anthropic message stream event to convert. + * @returns The converted Genkit Part object if the event is a content block + * start or delta, otherwise undefined. + */ +function fromAnthropicContentBlock(contentBlock: ContentBlock): Part { + if (contentBlock.type === 'tool_use') { + return { + toolRequest: { + ref: contentBlock.id, + name: contentBlock.name, + input: contentBlock.input, + }, + }; + } else if (contentBlock.type === 'text') { + return { text: contentBlock.text }; + } else if (contentBlock.type === 'thinking') { + return { text: contentBlock.thinking }; + } else if (contentBlock.type === 'redacted_thinking') { + return { text: contentBlock.data }; + } else { + // Handle other content block types + return { text: '' }; + } +} + +/** + * Converts an Anthropic message stream event to a Genkit Part object. + */ +export function fromAnthropicContentBlockChunk( + event: MessageStreamEvent +): Part | undefined { + if ( + event.type !== 'content_block_start' && + event.type !== 'content_block_delta' + ) { + return; + } + const eventField = + event.type === 'content_block_start' ? 'content_block' : 'delta'; + return ['text', 'text_delta'].includes(event[eventField].type) + ? { + text: event[eventField].text, + } + : { + toolRequest: { + ref: event[eventField].id, + name: event[eventField].name, + input: event[eventField].input, + }, + }; +} + +export function fromAnthropicStopReason( + reason: Message['stop_reason'] + // TODO: CandidateData is deprecated +): CandidateData['finishReason'] { + switch (reason) { + case 'max_tokens': + return 'length'; + case 'end_turn': + // fall through + case 'stop_sequence': + // fall through + case 'tool_use': + return 'stop'; + case null: + return 'unknown'; + default: + return 'other'; + } +} + +export function fromAnthropicResponse(response: Message): GenerateResponseData { + return { + candidates: [ + { + index: 0, + finishReason: fromAnthropicStopReason(response.stop_reason), + message: { + role: 'model', + content: response.content.map(fromAnthropicContentBlock), + }, + }, + ], + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + custom: response, + }; +} + +/** + * Converts an Anthropic request to an Anthropic API request body. + * @param modelName The name of the Anthropic model to use. + * @param request The Genkit GenerateRequest to convert. + * @param stream Whether to stream the response. + * @param cacheSystemPrompt Whether to cache the system prompt. + * @returns The converted Anthropic API request body. + * @throws An error if the specified model is not supported or if an unsupported output format is requested. + */ +export function toAnthropicRequestBody( + modelName: string, + request: GenerateRequest, + stream?: boolean, + cacheSystemPrompt?: boolean +): MessageCreateParams { + const model = SUPPORTED_CLAUDE_MODELS[modelName]; + if (!model) throw new Error(`Unsupported model: ${modelName}`); + const { system, messages } = toAnthropicMessages(request.messages); + const mappedModelName = request.config?.version ?? model.version ?? modelName; + const body: MessageCreateParams = { + system: + cacheSystemPrompt && system + ? [ + { + type: 'text', + text: system, + cache_control: { type: 'ephemeral' }, + }, + ] + : system, + messages, + tools: request.tools?.map(toAnthropicTool), + max_tokens: request.config?.maxOutputTokens ?? 4096, + model: mappedModelName, + top_k: request.config?.topK, + top_p: request.config?.topP, + temperature: request.config?.temperature, + stop_sequences: request.config?.stopSequences, + metadata: request.config?.metadata, + tool_choice: request.config?.tool_choice, + stream, + }; + + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + for (const key in body) { + if (!body[key] || (Array.isArray(body[key]) && !body[key].length)) + delete body[key]; + } + return body; +} + +/** + * Creates the runner used by Genkit to interact with the Claude model. + * @param name The name of the Claude model. + * @param client The Anthropic client instance. + * @param cacheSystemPrompt Whether to cache the system prompt. + * @returns The runner that Genkit will call when the model is invoked. + */ +export function claudeRunner( + name: string, + client: Anthropic, + cacheSystemPrompt?: boolean +) { + return async ( + request: GenerateRequest, + { + streamingRequested, + sendChunk, + abortSignal, + }: { + streamingRequested: boolean; + sendChunk: StreamingCallback; + abortSignal: AbortSignal; + } + ): Promise => { + let response: Message; + const body = toAnthropicRequestBody( + name, + request, + streamingRequested, + cacheSystemPrompt + ); + + if (streamingRequested) { + const stream = client.messages.stream(body, { signal: abortSignal }); + for await (const chunk of stream) { + const c = fromAnthropicContentBlockChunk(chunk); + if (c) { + sendChunk({ + index: 0, + content: [c], + }); + } + } + response = await stream.finalMessage(); + } else { + response = (await client.messages.create(body, { + signal: abortSignal, + })) as Message; + } + return fromAnthropicResponse(response); + }; +} + +/** + * Defines a Claude model with the given name and Anthropic client. + */ +export function claudeModel( + name: string, + client: Anthropic, + cacheSystemPrompt?: boolean +): ModelAction { + const modelRef = SUPPORTED_CLAUDE_MODELS[name]; + if (!modelRef) throw new Error(`Unsupported model: ${name}`); + + return model( + { + name: `anthropic/${name}`, + ...modelRef.info, + configSchema: modelRef.configSchema, + }, + claudeRunner(name, client, cacheSystemPrompt) + ); +} diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts new file mode 100644 index 0000000000..a57cf1c0be --- /dev/null +++ b/js/plugins/anthropic/src/index.ts @@ -0,0 +1,144 @@ +/** + * Copyright 2024 Bloom Labs Inc + * + * 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 { genkitPluginV2, model, modelActionMetadata } from 'genkit/plugin'; +import Anthropic from '@anthropic-ai/sdk'; + +import { + claude4Sonnet, + claude4Opus, + claude37Sonnet, + claude35Sonnet, + claude3Opus, + claude3Sonnet, + claude3Haiku, + claude35Haiku, + claudeModel, + SUPPORTED_CLAUDE_MODELS, +} from './claude.js'; +import { ModelAction } from 'genkit/model'; +import { ActionType } from 'genkit/registry'; +import { ActionMetadata } from 'genkit'; + +export { + claude4Sonnet, + claude4Opus, + claude37Sonnet, + claude35Sonnet, + claude3Opus, + claude3Sonnet, + claude3Haiku, + claude35Haiku, +}; + +export interface PluginOptions { + apiKey?: string; + cacheSystemPrompt?: boolean; +} + +async function list(client: Anthropic): Promise { + const clientModels = (await client.models.list()).data; + + return clientModels + .map((modelInfo) => { + // Remove the date suffix from the model id + const normalizedId = modelInfo.id.replace(/-\d{8}$/, ''); + // Get the model reference from the supported models + const ref = SUPPORTED_CLAUDE_MODELS[normalizedId]; + // Return the model action metadata if the model is supported + return ref ? modelActionMetadata({ + name: ref.name, + info: ref.info, + configSchema: ref.configSchema, + }) : undefined; + }) + // Filter out undefined values + .filter((metadata) => metadata !== undefined); +} + +/** + * This module provides an interface to the Anthropic AI models through the Genkit plugin system. + * It allows users to interact with various Claude models by providing an API key and optional configuration. + * + * The main export is the `anthropic` plugin, which can be configured with an API key either directly or through + * environment variables. It initializes the Anthropic client and makes available the Claude models for use. + * + * Exports: + * - claude35Sonnet: Reference to the Claude 3.5 Sonnet model. + * - claude3Haiku: Reference to the Claude 3 Haiku model. + * - claude3Sonnet: Reference to the Claude 3 Sonnet model. + * - claude3Opus: Reference to the Claude 3 Opus model. + * - anthropic: The main plugin function to interact with the Anthropic AI. + * + * Usage: + * To use the Claude models, initialize the anthropic plugin inside `configureGenkit` and pass the configuration options. If no API key is provided in the options, the environment variable `ANTHROPIC_API_KEY` must be set. If you want to cache the system prompt, set `cacheSystemPrompt` to `true`. **Note:** Prompt caching is in beta and may change. To learn more, see https://docs.anthropic.com/en/docs/prompt-caching. + * + * Example: + * ``` + * import anthropic from 'genkitx-anthropic'; + * + * export default configureGenkit({ + * plugins: [ + * anthropic({ apiKey: 'your-api-key', cacheSystemPrompt: false }) + * ... // other plugins + * ] + * }); + * ``` + */ +// TODO: add support for voyage embeddings and tool use (both not documented well in docs.anthropic.com) +export const anthropic = (options?: PluginOptions) => { + let apiKey = options?.apiKey || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error( + 'Please pass in the API key or set the ANTHROPIC_API_KEY environment variable' + ); + } + let defaultHeaders = {}; + if (options?.cacheSystemPrompt == true) { + defaultHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31'; + } + const client = new Anthropic({ apiKey, defaultHeaders }); + + let listActionsCache: ActionMetadata[] | null = null; + + return genkitPluginV2({ + name: 'anthropic', + init: async () => { + const actions: ModelAction[] = []; + for (const name of Object.keys(SUPPORTED_CLAUDE_MODELS)) { + const action = claudeModel(name, client, options?.cacheSystemPrompt); + actions.push(action); + } + return actions; + }, + resolve: (actionType: ActionType, name: string) => { + if (actionType === 'model') { + return claudeModel( + name, + client + ); + } + return undefined; + }, + list: async () => { + if (listActionsCache) return listActionsCache; + listActionsCache = await list(client); + return listActionsCache; + } + }); +}; + +export default anthropic; diff --git a/js/plugins/anthropic/tsconfig.json b/js/plugins/anthropic/tsconfig.json new file mode 100644 index 0000000000..596e2cf729 --- /dev/null +++ b/js/plugins/anthropic/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src"] +} diff --git a/js/plugins/anthropic/tsup.config.ts b/js/plugins/anthropic/tsup.config.ts new file mode 100644 index 0000000000..29e3c52894 --- /dev/null +++ b/js/plugins/anthropic/tsup.config.ts @@ -0,0 +1,24 @@ +/** + * Copyright 2024 Bloom Labs Inc + * + * 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 type { Options } from 'tsup'; +import { defineConfig } from 'tsup'; + +import { defaultOptions } from '../../tsup.common'; + +export default defineConfig({ + ...(defaultOptions as Options), +}); From 3cf225ffc0f63ad3b3170f4c07df44687a0b5b9d Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 27 Oct 2025 13:36:46 +0000 Subject: [PATCH 02/51] refactor(js/plugins/anthropic): make compatible with monorepo --- js/plugins/anthropic/jest.config.js | 16 +++++ js/plugins/anthropic/package.json | 29 +++++---- js/plugins/anthropic/src/claude.test.ts | 30 ++++------ js/plugins/anthropic/src/claude.ts | 41 +++++++------ js/plugins/anthropic/src/index.ts | 79 +++++++++++++------------ js/plugins/anthropic/tsup.config.ts | 6 +- js/pnpm-lock.yaml | 52 ++++++++++++++++ 7 files changed, 161 insertions(+), 92 deletions(-) diff --git a/js/plugins/anthropic/jest.config.js b/js/plugins/anthropic/jest.config.js index d0fe51d60c..0a79e81edc 100644 --- a/js/plugins/anthropic/jest.config.js +++ b/js/plugins/anthropic/jest.config.js @@ -1,3 +1,19 @@ +/** + * 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. + */ + /** @type {import('ts-jest').JestConfigWithTsJest} */ module.exports = { preset: 'ts-jest', diff --git a/js/plugins/anthropic/package.json b/js/plugins/anthropic/package.json index b45c188ad5..ac59139934 100644 --- a/js/plugins/anthropic/package.json +++ b/js/plugins/anthropic/package.json @@ -1,6 +1,6 @@ { - "name": "genkitx-anthropic", - "description": "Firebase Genkit AI framework plugin for Anthropic APIs.", + "name": "@genkit-ai/anthropic", + "description": "Genkit AI framework plugin for Anthropic APIs.", "keywords": [ "genkit", "genkit-plugin", @@ -16,36 +16,39 @@ "genai", "generative-ai" ], - "version": "0.25.0", + "version": "1.21.0", "type": "commonjs", "repository": { "type": "git", - "url": "git+https://github.com/BloomLabsInc/genkit-plugins.git", - "directory": "plugins/anthropic" + "url": "https://github.com/firebase/genkit.git", + "directory": "js/plugins/anthropic" }, - "author": "BloomLabsInc", + "author": "genkit", "license": "Apache-2.0", "peerDependencies": { - "genkit": "^1.19.3" + "genkit": "workspace:^" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0" }, "devDependencies": { "@jest/globals": "^29.7.0", - "@types/node": "^20.12.12", + "@types/node": "^20.11.16", + "genkit": "workspace:*", "jest": "^29.7.0", "npm-run-all": "^4.1.5", + "rimraf": "^6.0.1", "ts-jest": "^29.1.2", - "tsup": "^8.0.2", - "typescript": "^5.4.5" + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^4.9.0" }, "types": "./lib/index.d.ts", "exports": { ".": { + "types": "./lib/index.d.ts", "require": "./lib/index.js", "import": "./lib/index.mjs", - "types": "./lib/index.d.ts", "default": "./lib/index.js" } }, @@ -59,9 +62,9 @@ "scripts": { "check": "tsc", "compile": "tsup-node", - "build:clean": "rm -rf ./lib", + "build:clean": "rimraf ./lib", "build": "npm-run-all build:clean check compile", "build:watch": "tsup-node --watch", - "test": "jest --coverage" + "test": "jest --verbose" } } diff --git a/js/plugins/anthropic/src/claude.test.ts b/js/plugins/anthropic/src/claude.test.ts index b72e42fbf4..b5a31b684e 100644 --- a/js/plugins/anthropic/src/claude.test.ts +++ b/js/plugins/anthropic/src/claude.test.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Bloom Labs Inc + * 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. @@ -14,29 +14,21 @@ * limitations under the License. */ -import { - describe, - it, - expect, - beforeEach, - afterEach, - jest, -} from '@jest/globals'; -import type { - GenerateResponseData, - Part, - GenerateRequest, - MessageData, - Role, - Genkit, -} from 'genkit'; +import type Anthropic from '@anthropic-ai/sdk'; import type { Message, - MessageParam, MessageCreateParams, + MessageParam, MessageStreamEvent, } from '@anthropic-ai/sdk/resources/messages.mjs'; -import type Anthropic from '@anthropic-ai/sdk'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import type { + GenerateRequest, + GenerateResponseData, + MessageData, + Part, + Role, +} from 'genkit'; import type { CandidateData, ToolDefinition } from 'genkit/model'; import type { AnthropicConfigSchema } from './claude'; diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index cef7355abe..e140dbc642 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Bloom Labs Inc + * 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. @@ -14,17 +14,34 @@ * limitations under the License. */ -import { Message as GenkitMessage, z } from 'genkit'; +import type Anthropic from '@anthropic-ai/sdk'; +import type { + ContentBlock, + ImageBlockParam, + Message, + MessageCreateParams, + MessageParam, + MessageStreamEvent, + TextBlock, + TextBlockParam, + Tool, + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/messages.mjs'; import type { - GenerateResponseData, GenerateRequest, + GenerateResponseData, MessageData, + ModelReference, Part, Role, StreamingCallback, - ModelReference, } from 'genkit'; -import { GenerationCommonConfigSchema } from 'genkit'; +import { + GenerationCommonConfigSchema, + Message as GenkitMessage, + z, +} from 'genkit'; import type { CandidateData, GenerateResponseChunkData, @@ -32,20 +49,6 @@ import type { ToolDefinition, } from 'genkit/model'; import { modelRef } from 'genkit/model'; -import type Anthropic from '@anthropic-ai/sdk'; -import type { - ImageBlockParam, - TextBlock, - TextBlockParam, - MessageCreateParams, - Tool, - ToolResultBlockParam, - ContentBlock, - Message, - MessageParam, - MessageStreamEvent, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages.mjs'; import { model } from 'genkit/plugin'; export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index a57cf1c0be..fe2971cb8e 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Bloom Labs Inc + * 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. @@ -14,34 +14,38 @@ * limitations under the License. */ -import { genkitPluginV2, model, modelActionMetadata } from 'genkit/plugin'; import Anthropic from '@anthropic-ai/sdk'; +import { + genkitPluginV2, + modelActionMetadata, + type GenkitPluginV2, +} from 'genkit/plugin'; +import { ActionMetadata } from 'genkit'; +import { ModelAction } from 'genkit/model'; +import { ActionType } from 'genkit/registry'; import { - claude4Sonnet, - claude4Opus, - claude37Sonnet, + SUPPORTED_CLAUDE_MODELS, + claude35Haiku, claude35Sonnet, + claude37Sonnet, + claude3Haiku, claude3Opus, claude3Sonnet, - claude3Haiku, - claude35Haiku, + claude4Opus, + claude4Sonnet, claudeModel, - SUPPORTED_CLAUDE_MODELS, } from './claude.js'; -import { ModelAction } from 'genkit/model'; -import { ActionType } from 'genkit/registry'; -import { ActionMetadata } from 'genkit'; export { - claude4Sonnet, - claude4Opus, - claude37Sonnet, + claude35Haiku, claude35Sonnet, + claude37Sonnet, + claude3Haiku, claude3Opus, claude3Sonnet, - claude3Haiku, - claude35Haiku, + claude4Opus, + claude4Sonnet, }; export interface PluginOptions { @@ -51,22 +55,26 @@ export interface PluginOptions { async function list(client: Anthropic): Promise { const clientModels = (await client.models.list()).data; + const result: ActionMetadata[] = []; + + for (const modelInfo of clientModels) { + // Remove the date suffix from the model id + const normalizedId = modelInfo.id.replace(/-\d{8}$/, ''); + // Get the model reference from the supported models + const ref = SUPPORTED_CLAUDE_MODELS[normalizedId]; + // Add the model action metadata if the model is supported + if (ref) { + result.push( + modelActionMetadata({ + name: ref.name, + info: ref.info, + configSchema: ref.configSchema, + }) + ); + } + } - return clientModels - .map((modelInfo) => { - // Remove the date suffix from the model id - const normalizedId = modelInfo.id.replace(/-\d{8}$/, ''); - // Get the model reference from the supported models - const ref = SUPPORTED_CLAUDE_MODELS[normalizedId]; - // Return the model action metadata if the model is supported - return ref ? modelActionMetadata({ - name: ref.name, - info: ref.info, - configSchema: ref.configSchema, - }) : undefined; - }) - // Filter out undefined values - .filter((metadata) => metadata !== undefined); + return result; } /** @@ -99,7 +107,7 @@ async function list(client: Anthropic): Promise { * ``` */ // TODO: add support for voyage embeddings and tool use (both not documented well in docs.anthropic.com) -export const anthropic = (options?: PluginOptions) => { +export const anthropic = (options?: PluginOptions): GenkitPluginV2 => { let apiKey = options?.apiKey || process.env.ANTHROPIC_API_KEY; if (!apiKey) { throw new Error( @@ -126,10 +134,7 @@ export const anthropic = (options?: PluginOptions) => { }, resolve: (actionType: ActionType, name: string) => { if (actionType === 'model') { - return claudeModel( - name, - client - ); + return claudeModel(name, client); } return undefined; }, @@ -137,7 +142,7 @@ export const anthropic = (options?: PluginOptions) => { if (listActionsCache) return listActionsCache; listActionsCache = await list(client); return listActionsCache; - } + }, }); }; diff --git a/js/plugins/anthropic/tsup.config.ts b/js/plugins/anthropic/tsup.config.ts index 29e3c52894..d55507161f 100644 --- a/js/plugins/anthropic/tsup.config.ts +++ b/js/plugins/anthropic/tsup.config.ts @@ -1,5 +1,5 @@ /** - * Copyright 2024 Bloom Labs Inc + * 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. @@ -14,9 +14,7 @@ * limitations under the License. */ -import type { Options } from 'tsup'; -import { defineConfig } from 'tsup'; - +import { defineConfig, type Options } from 'tsup'; import { defaultOptions } from '../../tsup.common'; export default defineConfig({ diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 00f3027cfd..9741fcbece 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -257,6 +257,43 @@ importers: specifier: ^4.9.0 version: 4.9.5 + plugins/anthropic: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.39.0 + version: 0.39.0(encoding@0.1.13) + devDependencies: + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 + '@types/node': + specifier: ^20.11.16 + version: 20.19.1 + genkit: + specifier: workspace:* + version: link:../../genkit + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.1)(ts-node@10.9.2(@types/node@20.19.1)(typescript@4.9.5)) + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + ts-jest: + specifier: ^29.1.2 + version: 29.4.0(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(ts-node@10.9.2(@types/node@20.19.1)(typescript@4.9.5)))(typescript@4.9.5) + tsup: + specifier: ^8.3.5 + version: 8.5.0(postcss@8.4.47)(tsx@4.20.3)(typescript@4.9.5)(yaml@2.8.0) + tsx: + specifier: ^4.19.2 + version: 4.20.3 + typescript: + specifier: ^4.9.0 + version: 4.9.5 + plugins/checks: dependencies: '@genkit-ai/ai': @@ -2036,6 +2073,9 @@ packages: '@anthropic-ai/sdk@0.24.3': resolution: {integrity: sha512-916wJXO6T6k8R6BAAcLhLPv/pnLGy7YSEBZXZ1XTFbLcTZE8oTy3oDW9WJf9KKZwMvVcePIfoTSvzXHRcGxkQQ==} + '@anthropic-ai/sdk@0.39.0': + resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==} + '@anthropic-ai/sdk@0.9.1': resolution: {integrity: sha512-wa1meQ2WSfoY8Uor3EdrJq0jTiZJoKoSii2ZVWRY1oN4Tlr5s59pADg9T79FTbPe1/se5c3pBeZgJL63wmuoBA==} @@ -7815,6 +7855,18 @@ snapshots: transitivePeerDependencies: - encoding + '@anthropic-ai/sdk@0.39.0(encoding@0.1.13)': + dependencies: + '@types/node': 18.19.112 + '@types/node-fetch': 2.6.12 + abort-controller: 3.0.0 + agentkeepalive: 4.5.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + '@anthropic-ai/sdk@0.9.1(encoding@0.1.13)': dependencies: '@types/node': 18.19.112 From e83d82b61ff84ebc7591f9795aac1f0ed05265ce Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 27 Oct 2025 15:09:32 +0000 Subject: [PATCH 03/51] feat(js/plugins/anthropic): convert tests and add test setup --- js/plugins/anthropic/jest.config.js | 22 -- js/plugins/anthropic/package.json | 7 +- .../converters_test.ts} | 256 +++++++++-------- js/plugins/anthropic/tests/index_test.ts | 62 ++++ .../anthropic/tests/integration_test.ts | 61 ++++ js/plugins/anthropic/tests/mocks/README.md | 207 ++++++++++++++ .../anthropic/tests/mocks/anthropic-client.ts | 264 ++++++++++++++++++ .../tests/mocks/setup-anthropic-mock.ts | 104 +++++++ js/pnpm-lock.yaml | 9 - 9 files changed, 826 insertions(+), 166 deletions(-) delete mode 100644 js/plugins/anthropic/jest.config.js rename js/plugins/anthropic/{src/claude.test.ts => tests/converters_test.ts} (82%) create mode 100644 js/plugins/anthropic/tests/index_test.ts create mode 100644 js/plugins/anthropic/tests/integration_test.ts create mode 100644 js/plugins/anthropic/tests/mocks/README.md create mode 100644 js/plugins/anthropic/tests/mocks/anthropic-client.ts create mode 100644 js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts diff --git a/js/plugins/anthropic/jest.config.js b/js/plugins/anthropic/jest.config.js deleted file mode 100644 index 0a79e81edc..0000000000 --- a/js/plugins/anthropic/jest.config.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * 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. - */ - -/** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testPathIgnorePatterns: ['/node_modules/', '/lib/'], -}; diff --git a/js/plugins/anthropic/package.json b/js/plugins/anthropic/package.json index ac59139934..6a69d6849c 100644 --- a/js/plugins/anthropic/package.json +++ b/js/plugins/anthropic/package.json @@ -32,13 +32,10 @@ "@anthropic-ai/sdk": "^0.39.0" }, "devDependencies": { - "@jest/globals": "^29.7.0", "@types/node": "^20.11.16", "genkit": "workspace:*", - "jest": "^29.7.0", "npm-run-all": "^4.1.5", "rimraf": "^6.0.1", - "ts-jest": "^29.1.2", "tsup": "^8.3.5", "tsx": "^4.19.2", "typescript": "^4.9.0" @@ -65,6 +62,8 @@ "build:clean": "rimraf ./lib", "build": "npm-run-all build:clean check compile", "build:watch": "tsup-node --watch", - "test": "jest --verbose" + "test": "tsx --test --experimental-test-module-mocks ./tests/**/*_test.ts", + "test:file": "tsx --test --experimental-test-module-mocks", + "test:coverage": "tsx --test --experimental-test-module-mocks --experimental-test-coverage --test-coverage-include='src/**/*.ts' ./tests/**/*_test.ts" } } diff --git a/js/plugins/anthropic/src/claude.test.ts b/js/plugins/anthropic/tests/converters_test.ts similarity index 82% rename from js/plugins/anthropic/src/claude.test.ts rename to js/plugins/anthropic/tests/converters_test.ts index b5a31b684e..045ce84e47 100644 --- a/js/plugins/anthropic/src/claude.test.ts +++ b/js/plugins/anthropic/tests/converters_test.ts @@ -21,7 +21,7 @@ import type { MessageParam, MessageStreamEvent, } from '@anthropic-ai/sdk/resources/messages.mjs'; -import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import * as assert from 'assert'; import type { GenerateRequest, GenerateResponseData, @@ -30,10 +30,10 @@ import type { Role, } from 'genkit'; import type { CandidateData, ToolDefinition } from 'genkit/model'; +import { describe, it, mock } from 'node:test'; -import type { AnthropicConfigSchema } from './claude'; +import type { AnthropicConfigSchema } from '../src/claude.js'; import { - claude35Haiku, claudeModel, claudeRunner, fromAnthropicContentBlockChunk, @@ -45,12 +45,8 @@ import { toAnthropicRole, toAnthropicTool, toAnthropicToolResponseContent, -} from './claude'; - -jest.mock('genkit/plugin', () => ({ - ...(jest.requireActual('genkit/plugin') as object), - model: jest.fn(() => ({})), -})); +} from '../src/claude.js'; +import { createMockAnthropicClient } from './mocks/anthropic-client.js'; describe('toAnthropicRole', () => { const testCases: { @@ -88,13 +84,14 @@ describe('toAnthropicRole', () => { test.genkitRole, test.toolMessageType ); - expect(actualOutput).toBe(test.expectedAnthropicRole); + assert.strictEqual(actualOutput, test.expectedAnthropicRole); }); } it('should throw an error for unknown roles', () => { - expect(() => toAnthropicRole('unknown' as Role)).toThrowError( - "role unknown doesn't map to an Anthropic role." + assert.throws( + () => toAnthropicRole('unknown' as Role), + /role unknown doesn't map to an Anthropic role\./ ); }); }); @@ -102,30 +99,30 @@ describe('toAnthropicRole', () => { describe('toAnthropicToolResponseContent', () => { it('should throw an error for unknown parts', () => { const part: Part = { data: 'hi' } as Part; - expect(() => toAnthropicToolResponseContent(part)).toThrowError( - `Invalid genkit part provided to toAnthropicToolResponseContent: {"data":"hi"}` + assert.throws( + () => toAnthropicToolResponseContent(part), + /Invalid genkit part provided to toAnthropicToolResponseContent: {"data":"hi"}/ ); }); }); describe('toAnthropicMessageContent', () => { it('should throw if a media part contains invalid media', () => { - expect(() => - toAnthropicMessageContent({ - media: { - url: '', - }, - }) - ).toThrowError( - 'Invalid genkit part media provided to toAnthropicMessageContent: {"url":""}' + assert.throws( + () => + toAnthropicMessageContent({ + media: { + url: '', + }, + }), + /Invalid genkit part media provided to toAnthropicMessageContent: {"url":""}/ ); }); it('should throw if the provided part is invalid', () => { - expect(() => - toAnthropicMessageContent({ fake: 'part' } as Part) - ).toThrowError( - 'Unsupported genkit part fields encountered for current message role: {"fake":"part"}' + assert.throws( + () => toAnthropicMessageContent({ fake: 'part' } as Part), + /Unsupported genkit part fields encountered for current message role: {"fake":"part"}/ ); }); }); @@ -405,7 +402,7 @@ describe('toAnthropicMessages', () => { for (const test of testCases) { it(test.should, () => { const actualOutput = toAnthropicMessages(test.inputMessages); - expect(actualOutput).toStrictEqual(test.expectedOutput); + assert.deepStrictEqual(actualOutput, test.expectedOutput); }); } }); @@ -424,7 +421,7 @@ describe('toAnthropicTool', () => { }, }; const actualOutput = toAnthropicTool(tool); - expect(actualOutput).toStrictEqual({ + assert.deepStrictEqual(actualOutput, { name: 'tellAJoke', description: 'Tell a joke', input_schema: { @@ -501,7 +498,7 @@ describe('fromAnthropicContentBlockChunk', () => { for (const test of testCases) { it(test.should, () => { const actualOutput = fromAnthropicContentBlockChunk(test.event); - expect(actualOutput).toStrictEqual(test.expectedOutput); + assert.deepStrictEqual(actualOutput, test.expectedOutput); }); } }); @@ -540,7 +537,7 @@ describe('fromAnthropicStopReason', () => { for (const test of testCases) { it(`should map Anthropic stop reason "${test.inputStopReason}" to Genkit finish reason "${test.expectedFinishReason}"`, () => { const actualOutput = fromAnthropicStopReason(test.inputStopReason); - expect(actualOutput).toBe(test.expectedFinishReason); + assert.strictEqual(actualOutput, test.expectedFinishReason); }); } }); @@ -549,7 +546,7 @@ describe('fromAnthropicResponse', () => { const testCases: { should: string; message: Message; - expectedOutput: GenerateResponseData; + expectedOutput: Omit; }[] = [ { should: 'should work with text content', @@ -589,7 +586,6 @@ describe('fromAnthropicResponse', () => { inputTokens: 10, outputTokens: 20, }, - custom: expect.any(Object), }, }, { @@ -639,7 +635,6 @@ describe('fromAnthropicResponse', () => { inputTokens: 10, outputTokens: 20, }, - custom: expect.any(Object), }, }, ]; @@ -647,7 +642,17 @@ describe('fromAnthropicResponse', () => { for (const test of testCases) { it(test.should, () => { const actualOutput = fromAnthropicResponse(test.message); - expect(actualOutput).toStrictEqual(test.expectedOutput); + // Check custom field exists and is the message + assert.ok(actualOutput.custom); + assert.strictEqual(actualOutput.custom, test.message); + // Check the rest + assert.deepStrictEqual( + { + candidates: actualOutput.candidates, + usage: actualOutput.usage, + }, + test.expectedOutput + ); }); } }); @@ -836,27 +841,29 @@ describe('toAnthropicRequestBody', () => { test.modelName, test.genkitRequest ); - expect(actualOutput).toStrictEqual(test.expectedOutput); + assert.deepStrictEqual(actualOutput, test.expectedOutput); }); } it('should throw if model is not supported', () => { - expect(() => - toAnthropicRequestBody('fake-model', { - messages: [], - } as GenerateRequest) - ).toThrowError('Unsupported model: fake-model'); + assert.throws( + () => + toAnthropicRequestBody('fake-model', { + messages: [], + } as GenerateRequest), + /Unsupported model: fake-model/ + ); }); it('should throw if output format is not text', () => { - expect(() => - toAnthropicRequestBody('claude-3-5-haiku', { - messages: [], - tools: [], - output: { format: 'media' }, - } as GenerateRequest) - ).toThrowError( - 'Only text output format is supported for Claude models currently' + assert.throws( + () => + toAnthropicRequestBody('claude-3-5-haiku', { + messages: [], + tools: [], + output: { format: 'media' }, + } as GenerateRequest), + /Only text output format is supported for Claude models currently/ ); }); @@ -876,7 +883,7 @@ describe('toAnthropicRequestBody', () => { false, true ); - expect(outputWithCaching.system).toEqual([ + assert.deepStrictEqual(outputWithCaching.system, [ { type: 'text', text: 'You are a helpful assistant', @@ -891,95 +898,81 @@ describe('toAnthropicRequestBody', () => { false, false ); - expect(outputWithoutCaching.system).toBe('You are a helpful assistant'); + assert.strictEqual( + outputWithoutCaching.system, + 'You are a helpful assistant' + ); }); }); describe('claudeRunner', () => { it('should correctly run non-streaming requests', async () => { - const anthropicClient = { - messages: { - create: jest.fn(async () => ({ - content: [{ type: 'text', text: 'response' }], - usage: { - input_tokens: 10, - output_tokens: 20, - }, - })), + const mockClient = createMockAnthropicClient({ + messageResponse: { + content: [{ type: 'text', text: 'response', citations: null }], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, }, - }; - const runner = claudeRunner( - 'claude-3-5-haiku', - anthropicClient as unknown as Anthropic - ); + }); + + const runner = claudeRunner('claude-3-5-haiku', mockClient); const abortSignal = new AbortController().signal; await runner( { messages: [] }, { streamingRequested: false, sendChunk: () => {}, abortSignal } ); - expect(anthropicClient.messages.create).toHaveBeenCalledWith( + + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + assert.deepStrictEqual(createStub.mock.calls[0].arguments, [ { model: 'claude-3-5-haiku-latest', max_tokens: 4096, }, { signal: abortSignal, - } - ); + }, + ]); }); it('should correctly run streaming requests', async () => { - const anthropicClient = { - messages: { - stream: jest.fn( - () => - // Simulate Anthropic SDK request streaming - new (class { - isFirstRequest = true; - [Symbol.asyncIterator]() { - return { - next: async () => { - const returnValue = this.isFirstRequest - ? { - value: { - type: 'content_block_start', - content_block: { - type: 'text', - text: 'res', - }, - }, - done: false, - } - : { done: true }; - this.isFirstRequest = false; - return returnValue; - }, - }; - } - async finalMessage() { - return { - content: [{ type: 'text', text: 'response' }], - usage: { - input_tokens: 10, - output_tokens: 20, - }, - }; - } - })() - ), + const mockClient = createMockAnthropicClient({ + streamChunks: [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'text', + text: 'res', + }, + } as MessageStreamEvent, + ], + messageResponse: { + content: [{ type: 'text', text: 'response', citations: null }], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, }, - }; - const streamingCallback = jest.fn(); - const runner = claudeRunner( - 'claude-3-5-haiku', - anthropicClient as unknown as Anthropic - ); + }); + + const streamingCallback = mock.fn(); + const runner = claudeRunner('claude-3-5-haiku', mockClient); const abortSignal = new AbortController().signal; await runner( { messages: [] }, { streamingRequested: true, sendChunk: streamingCallback, abortSignal } ); - expect(anthropicClient.messages.stream).toHaveBeenCalledWith( + + const streamStub = mockClient.messages.stream as any; + assert.strictEqual(streamStub.mock.calls.length, 1); + assert.deepStrictEqual(streamStub.mock.calls[0].arguments, [ { model: 'claude-3-5-haiku-latest', max_tokens: 4096, @@ -987,33 +980,34 @@ describe('claudeRunner', () => { }, { signal: abortSignal, - } - ); + }, + ]); }); }); describe('claudeModel', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('should correctly define supported Claude models', () => { - const { model } = jest.requireMock('genkit/plugin') as { model: jest.Mock }; + const mockClient = createMockAnthropicClient(); const modelName = 'claude-3-5-haiku'; - claudeModel(modelName, {} as Anthropic); - expect(model).toHaveBeenCalledWith( - { - name: `anthropic/${modelName}`, - ...claude35Haiku.info, - configSchema: claude35Haiku.configSchema, - }, - expect.any(Function) - ); + const modelAction = claudeModel(modelName, mockClient); + + // Verify the model action is returned + assert.ok(modelAction); + assert.strictEqual(typeof modelAction, 'function'); }); it('should throw for unsupported models', () => { - expect(() => - claudeModel('unsupported-model', {} as Anthropic) - ).toThrowError('Unsupported model: unsupported-model'); + assert.throws( + () => claudeModel('unsupported-model', {} as Anthropic), + /Unsupported model: unsupported-model/ + ); }); + + it.todo('should handle streaming with multiple text chunks'); + + it.todo('should handle tool use in streaming mode'); + + it.todo('should handle streaming errors and partial responses'); + + it.todo('should handle abort signal during streaming'); }); diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts new file mode 100644 index 0000000000..7fec2ebcd0 --- /dev/null +++ b/js/plugins/anthropic/tests/index_test.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 * as assert from 'assert'; +import { genkit } from 'genkit'; +import { before, describe, it } from 'node:test'; +import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; + +setupAnthropicMock(); + +describe('Anthropic Plugin', () => { + let anthropic: any; + let SUPPORTED_CLAUDE_MODELS: any; + + before(async () => { + process.env.ANTHROPIC_API_KEY = 'test-api-key'; + + // Import after mocking is set up + const indexModule = await import('../src/index.js'); + anthropic = indexModule.anthropic; + + const claudeModule = await import('../src/claude.js'); + SUPPORTED_CLAUDE_MODELS = claudeModule.SUPPORTED_CLAUDE_MODELS; + }); + + it('should register all supported Claude models', async () => { + const ai = genkit({ + plugins: [anthropic()], + }); + + for (const modelName of Object.keys(SUPPORTED_CLAUDE_MODELS)) { + const modelPath = `/model/anthropic/${modelName}`; + const expectedBaseName = `anthropic/${modelName}`; + const model = await ai.registry.lookupAction(modelPath); + assert.ok(model, `${modelName} should be registered at ${modelPath}`); + assert.strictEqual(model?.__action.name, expectedBaseName); + } + }); + + it.todo('should throw error when API key is missing'); + + it.todo('should use API key from environment variable'); + + it.todo('should resolve models dynamically via resolve function'); + + it.todo('should list available models from API'); + + it.todo('should cache list results on subsequent calls?'); +}); diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts new file mode 100644 index 0000000000..939e8cf9d6 --- /dev/null +++ b/js/plugins/anthropic/tests/integration_test.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 * as assert from 'assert'; +import { genkit, type Genkit } from 'genkit'; +import { before, describe, it } from 'node:test'; +import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; + +setupAnthropicMock(); + +describe('Anthropic Integration', () => { + let ai: Genkit; + + before(async () => { + process.env.ANTHROPIC_API_KEY = 'test-api-key'; + + // Import and initialize after mocking + const { anthropic } = await import('../src/index.js'); + ai = genkit({ + plugins: [anthropic()], + }); + }); + + it('should successfully generate a response', async () => { + const result = await ai.generate({ + model: 'anthropic/claude-3-5-sonnet', + prompt: 'Hello', + }); + + assert.strictEqual(result.text, 'Hello! How can I help you today?'); + }); + + it.todo( + 'should handle tool calling workflow (call tool, receive result, generate final response)' + ); + + it.todo('should handle multi-turn conversations'); + + it.todo('should stream responses with streaming callback'); + + it.todo('should handle media/image inputs'); + + it.todo('should propagate API errors correctly'); + + it.todo('should respect abort signals for cancellation'); + + it.todo('should track token usage in responses'); +}); diff --git a/js/plugins/anthropic/tests/mocks/README.md b/js/plugins/anthropic/tests/mocks/README.md new file mode 100644 index 0000000000..4fd5554115 --- /dev/null +++ b/js/plugins/anthropic/tests/mocks/README.md @@ -0,0 +1,207 @@ +/** + * 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. + */ + +# Anthropic Plugin Test Mocks + +Mock utilities for testing the Anthropic plugin without making real API calls. + +## Quick Start + +```typescript +import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; + +// Set up SDK mocking (call at module level, before describe blocks) +setupAnthropicMock(); + +describe('My Test Suite', () => { + // Your tests here +}); +``` + +## setupAnthropicMock() + +Sets up mocking for the Anthropic SDK module. Must be called at the module level before any describe blocks. + +### Basic Usage + +```typescript +setupAnthropicMock(); +``` + +### Custom Response + +```typescript +setupAnthropicMock({ + messageResponse: { + content: [{ type: 'text', text: 'Custom response' }] + } +}); +``` + +### Return Value + +Returns `{ mockClient, mockResponse }` for making assertions in tests. + +```typescript +const { mockClient } = setupAnthropicMock(); + +// Later in tests, verify SDK was called +assert.ok(mockClient.messages.create.mock.calls.length > 0); +``` + +## createMockAnthropicMessage() + +Creates customizable mock Anthropic Message responses for more complex test scenarios. + +### Text Response + +```typescript +import { createMockAnthropicMessage } from './mocks/anthropic-client.js'; + +const message = createMockAnthropicMessage({ + text: 'Hello from test' +}); +``` + +### Tool Use Response + +```typescript +const message = createMockAnthropicMessage({ + toolUse: { + name: 'get_weather', + input: { city: 'NYC' } + } +}); +``` + +### Custom Tokens + +```typescript +const message = createMockAnthropicMessage({ + usage: { + input_tokens: 100, + output_tokens: 500 + } +}); +``` + +### All Options + +```typescript +const message = createMockAnthropicMessage({ + id: 'msg_custom', // Default: 'msg_test123' + text: 'Custom text', // Default: 'Hello! How can I help you today?' + toolUse: { // Mutually exclusive with text + id: 'tool_123', // Default: 'toolu_test123' + name: 'tool_name', // Required + input: {} // Required + }, + stopReason: 'end_turn', // Default: 'end_turn' or 'tool_use' + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0 + } +}); +``` + +## Other Helpers + +### createMockAnthropicClient() + +Creates a mock Anthropic client with configurable responses for more complex test scenarios. + +```typescript +import { createMockAnthropicClient } from './mocks/anthropic-client.js'; + +const mockClient = createMockAnthropicClient({ + messageResponse: { content: [{ type: 'text', text: 'Response' }] }, + streamChunks: [mockTextChunk('chunk1'), mockTextChunk('chunk2')], + shouldError: new Error('API Error') +}); +``` + +### Stream Event Helpers + +```typescript +import { + mockTextChunk, + mockContentBlockStart, + mockToolUseChunk +} from './mocks/anthropic-client.js'; + +// Text delta for streaming +const chunk = mockTextChunk('Hello'); + +// Content block start +const start = mockContentBlockStart('Starting text'); + +// Tool use event +const toolChunk = mockToolUseChunk('tool_id', 'tool_name', { arg: 'value' }); +``` + +## Pattern: Testing Plugin Lifecycle + +```typescript +import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; + +setupAnthropicMock(); + +describe('Plugin Tests', () => { + let anthropic: any; + + before(async () => { + const { anthropic: plugin } = await import('../src/index.js'); + anthropic = plugin; + }); + + it('should initialize correctly', () => { + // Test plugin initialization + }); +}); +``` + +## Pattern: Testing Integration + +```typescript +import { genkit } from 'genkit'; +import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; + +setupAnthropicMock({ + messageResponse: { + content: [{ type: 'text', text: 'Test response' }] + } +}); + +describe('Integration Tests', () => { + let ai: Genkit; + + before(async () => { + const { anthropic } = await import('../src/index.js'); + ai = genkit({ plugins: [anthropic()] }); + }); + + it('should generate responses', async () => { + const result = await ai.generate({ + model: 'anthropic/claude-3-5-sonnet', + prompt: 'Test' + }); + + assert.strictEqual(result.text, 'Test response'); + }); +}); +``` diff --git a/js/plugins/anthropic/tests/mocks/anthropic-client.ts b/js/plugins/anthropic/tests/mocks/anthropic-client.ts new file mode 100644 index 0000000000..f6e2b743ed --- /dev/null +++ b/js/plugins/anthropic/tests/mocks/anthropic-client.ts @@ -0,0 +1,264 @@ +/** + * 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 type Anthropic from '@anthropic-ai/sdk'; +import type { + Message, + MessageStreamEvent, +} from '@anthropic-ai/sdk/resources/messages.mjs'; +import { mock } from 'node:test'; + +export interface MockAnthropicClientOptions { + messageResponse?: Partial; + streamChunks?: MessageStreamEvent[]; + modelList?: Array<{ id: string; display_name?: string }>; + shouldError?: Error; +} + +/** + * Creates a mock Anthropic client for testing + */ +export function createMockAnthropicClient( + options: MockAnthropicClientOptions = {} +): Anthropic { + const messageResponse = { + ...mockDefaultMessage(), + ...options.messageResponse, + }; + + const createStub = options.shouldError + ? mock.fn(async () => { + throw options.shouldError; + }) + : mock.fn(async () => messageResponse); + + const streamStub = options.shouldError + ? mock.fn(() => { + throw options.shouldError; + }) + : mock.fn(() => + createMockStream(options.streamChunks || [], messageResponse as Message) + ); + + const listStub = options.shouldError + ? mock.fn(async () => { + throw options.shouldError; + }) + : mock.fn(async () => ({ + data: options.modelList || mockDefaultModels(), + })); + + return { + messages: { + create: createStub, + stream: streamStub, + }, + models: { + list: listStub, + }, + } as unknown as Anthropic; +} + +/** + * Creates a mock async iterable stream for streaming responses + */ +function createMockStream(chunks: MessageStreamEvent[], finalMsg: Message) { + let index = 0; + return { + [Symbol.asyncIterator]() { + return { + async next() { + if (index < chunks.length) { + return { value: chunks[index++], done: false }; + } + return { value: undefined, done: true }; + }, + }; + }, + async finalMessage() { + return finalMsg; + }, + }; +} + +export interface CreateMockAnthropicMessageOptions { + id?: string; + text?: string; + toolUse?: { + id?: string; + name: string; + input: any; + }; + stopReason?: Message['stop_reason']; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; +} + +/** + * Creates a customizable mock Anthropic Message response + * + * @example + * // Simple text response + * createMockAnthropicMessage({ text: 'Hi there!' }) + * + * // Tool use response + * createMockAnthropicMessage({ + * toolUse: { name: 'get_weather', input: { city: 'NYC' } } + * }) + * + * // Custom usage + * createMockAnthropicMessage({ usage: { input_tokens: 5, output_tokens: 15 } }) + */ +export function createMockAnthropicMessage( + options: CreateMockAnthropicMessageOptions = {} +): Message { + const content: Message['content'] = []; + + if (options.toolUse) { + content.push({ + type: 'tool_use', + id: options.toolUse.id || 'toolu_test123', + name: options.toolUse.name, + input: options.toolUse.input, + }); + } else { + content.push({ + type: 'text', + text: options.text || 'Hello! How can I help you today?', + citations: null, + }); + } + + return { + id: options.id || 'msg_test123', + type: 'message', + role: 'assistant', + model: 'claude-3-5-sonnet-20241022', + content, + stop_reason: + options.stopReason || (options.toolUse ? 'tool_use' : 'end_turn'), + stop_sequence: null, + usage: { + input_tokens: options.usage?.input_tokens ?? 10, + output_tokens: options.usage?.output_tokens ?? 20, + cache_creation_input_tokens: + options.usage?.cache_creation_input_tokens ?? 0, + cache_read_input_tokens: options.usage?.cache_read_input_tokens ?? 0, + }, + }; +} + +/** + * Creates a default mock Message response + */ +export function mockDefaultMessage(): Message { + return createMockAnthropicMessage(); +} + +/** + * Creates a mock text content block chunk event + */ +export function mockTextChunk(text: string): MessageStreamEvent { + return { + type: 'content_block_delta', + index: 0, + delta: { + type: 'text_delta', + text, + }, + } as MessageStreamEvent; +} + +/** + * Creates a mock content block start event with text + */ +export function mockContentBlockStart(text: string): MessageStreamEvent { + return { + type: 'content_block_start', + index: 0, + content_block: { + type: 'text', + text, + }, + } as MessageStreamEvent; +} + +/** + * Creates a mock tool use content block + */ +export function mockToolUseChunk( + id: string, + name: string, + input: any +): MessageStreamEvent { + return { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id, + name, + input, + }, + } as MessageStreamEvent; +} + +/** + * Creates a default list of mock models + */ +export function mockDefaultModels() { + return [ + { id: 'claude-3-5-sonnet-20241022', display_name: 'Claude 3.5 Sonnet' }, + { id: 'claude-3-5-haiku-20241022', display_name: 'Claude 3.5 Haiku' }, + { id: 'claude-3-opus-20240229', display_name: 'Claude 3 Opus' }, + ]; +} + +/** + * Creates a mock Message with tool use + */ +export function mockMessageWithToolUse( + toolName: string, + toolInput: any +): Partial { + return { + content: [ + { + type: 'tool_use', + id: 'toolu_test123', + name: toolName, + input: toolInput, + }, + ], + stop_reason: 'tool_use', + }; +} + +/** + * Creates a mock Message with custom content + */ +export function mockMessageWithContent( + content: Message['content'] +): Partial { + return { + content, + stop_reason: 'end_turn', + }; +} diff --git a/js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts b/js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts new file mode 100644 index 0000000000..18e5c5190f --- /dev/null +++ b/js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts @@ -0,0 +1,104 @@ +/** + * 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 type { Message } from '@anthropic-ai/sdk/resources/messages.mjs'; +import { mock } from 'node:test'; + +export interface SetupAnthropicMockOptions { + messageResponse?: Partial; +} + +export interface MockAnthropicClient { + messages: { + create: any; + stream: any; + }; + models: { + list: any; + }; +} + +export interface SetupAnthropicMockResult { + mockClient: MockAnthropicClient; + mockResponse: Message; +} + +/** + * Sets up mocking for the Anthropic SDK module. + * Must be called at the module level (before describe blocks). + * + * @param options - Configuration for mock responses + * @returns Mock client and response for assertions + * + * @example + * ```typescript + * import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; + * + * const { mockClient } = setupAnthropicMock({ + * messageResponse: { content: [{ type: 'text', text: 'Custom response' }] } + * }); + * + * describe('My Test Suite', () => { + * // tests here + * }); + * ``` + */ +export function setupAnthropicMock( + options: SetupAnthropicMockOptions = {} +): SetupAnthropicMockResult { + const mockResponse: Message = { + id: 'msg_test123', + type: 'message', + role: 'assistant', + model: 'claude-3-5-sonnet-20241022', + content: [ + { + type: 'text', + text: 'Hello! How can I help you today?', + citations: null, + }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + ...options.messageResponse, + }; + + const mockClient = { + messages: { + create: mock.fn(async () => mockResponse), + stream: mock.fn(), + }, + models: { + list: mock.fn(), + }, + }; + + const MockAnthropic = function (this: any, opts: any) { + return mockClient; + } as any; + + mock.module('@anthropic-ai/sdk', { + defaultExport: MockAnthropic, + }); + + return { mockClient, mockResponse }; +} diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 9741fcbece..48172f8ffe 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -263,27 +263,18 @@ importers: specifier: ^0.39.0 version: 0.39.0(encoding@0.1.13) devDependencies: - '@jest/globals': - specifier: ^29.7.0 - version: 29.7.0 '@types/node': specifier: ^20.11.16 version: 20.19.1 genkit: specifier: workspace:* version: link:../../genkit - jest: - specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.1)(ts-node@10.9.2(@types/node@20.19.1)(typescript@4.9.5)) npm-run-all: specifier: ^4.1.5 version: 4.1.5 rimraf: specifier: ^6.0.1 version: 6.0.1 - ts-jest: - specifier: ^29.1.2 - version: 29.4.0(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(ts-node@10.9.2(@types/node@20.19.1)(typescript@4.9.5)))(typescript@4.9.5) tsup: specifier: ^8.3.5 version: 8.5.0(postcss@8.4.47)(tsx@4.20.3)(typescript@4.9.5)(yaml@2.8.0) From f66c1014ccea3691aa652c722ba8f67d22ac388a Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 28 Oct 2025 11:38:37 +0300 Subject: [PATCH 04/51] chore(js/plugins/anthropic): remove retired claude-3-sonnet --- js/plugins/anthropic/src/claude.ts | 18 ------------------ js/plugins/anthropic/src/index.ts | 3 --- 2 files changed, 21 deletions(-) diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index e140dbc642..418850c1d6 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -149,23 +149,6 @@ export const claude3Opus = modelRef({ version: 'claude-3-opus-20240229', }); -export const claude3Sonnet = modelRef({ - name: 'claude-3-sonnet', - namespace: 'anthropic', - info: { - versions: ['claude-3-sonnet-20240229'], - label: 'Anthropic - Claude 3 Sonnet', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-3-sonnet-20240229', -}); export const claude3Haiku = modelRef({ name: 'claude-3-haiku', @@ -228,7 +211,6 @@ export const SUPPORTED_CLAUDE_MODELS: Record< 'claude-3-7-sonnet': claude37Sonnet, 'claude-3-5-sonnet': claude35Sonnet, 'claude-3-opus': claude3Opus, - 'claude-3-sonnet': claude3Sonnet, 'claude-3-haiku': claude3Haiku, 'claude-3-5-haiku': claude35Haiku, 'claude-4-sonnet': claude4Sonnet, diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index fe2971cb8e..2b1c124607 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -31,7 +31,6 @@ import { claude37Sonnet, claude3Haiku, claude3Opus, - claude3Sonnet, claude4Opus, claude4Sonnet, claudeModel, @@ -43,7 +42,6 @@ export { claude37Sonnet, claude3Haiku, claude3Opus, - claude3Sonnet, claude4Opus, claude4Sonnet, }; @@ -87,7 +85,6 @@ async function list(client: Anthropic): Promise { * Exports: * - claude35Sonnet: Reference to the Claude 3.5 Sonnet model. * - claude3Haiku: Reference to the Claude 3 Haiku model. - * - claude3Sonnet: Reference to the Claude 3 Sonnet model. * - claude3Opus: Reference to the Claude 3 Opus model. * - anthropic: The main plugin function to interact with the Anthropic AI. * From 48bc9494382f17ff67152d1b7f966c5ce1575240 Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 28 Oct 2025 11:45:20 +0300 Subject: [PATCH 05/51] chore(js/plugins/anthropic): remove retired claude-35-sonnet --- js/plugins/anthropic/src/claude.ts | 22 ---------------------- js/plugins/anthropic/src/index.ts | 3 --- 2 files changed, 25 deletions(-) diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index 418850c1d6..587b00f76a 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -109,27 +109,6 @@ export const claude37Sonnet = modelRef({ version: 'claude-3-7-sonnet-latest', }); -export const claude35Sonnet = modelRef({ - name: 'claude-3-5-sonnet', - namespace: 'anthropic', - info: { - versions: [ - 'claude-3-5-sonnet-20240620', - 'claude-3-5-sonnet-20241022', - 'claude-3-5-sonnet-latest', - ], - label: 'Anthropic - Claude 3.5 Sonnet', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-3-5-sonnet-latest', -}); export const claude3Opus = modelRef({ name: 'claude-3-opus', @@ -209,7 +188,6 @@ export const SUPPORTED_CLAUDE_MODELS: Record< ModelReference > = { 'claude-3-7-sonnet': claude37Sonnet, - 'claude-3-5-sonnet': claude35Sonnet, 'claude-3-opus': claude3Opus, 'claude-3-haiku': claude3Haiku, 'claude-3-5-haiku': claude35Haiku, diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 2b1c124607..4b455dcc16 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -27,7 +27,6 @@ import { ActionType } from 'genkit/registry'; import { SUPPORTED_CLAUDE_MODELS, claude35Haiku, - claude35Sonnet, claude37Sonnet, claude3Haiku, claude3Opus, @@ -38,7 +37,6 @@ import { export { claude35Haiku, - claude35Sonnet, claude37Sonnet, claude3Haiku, claude3Opus, @@ -83,7 +81,6 @@ async function list(client: Anthropic): Promise { * environment variables. It initializes the Anthropic client and makes available the Claude models for use. * * Exports: - * - claude35Sonnet: Reference to the Claude 3.5 Sonnet model. * - claude3Haiku: Reference to the Claude 3 Haiku model. * - claude3Opus: Reference to the Claude 3 Opus model. * - anthropic: The main plugin function to interact with the Anthropic AI. From 9403e051a30f53860680df6cf925cc175497289f Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Tue, 28 Oct 2025 11:57:22 +0300 Subject: [PATCH 06/51] chore(js/plugins/anthropic): format --- js/plugins/anthropic/src/claude.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index 587b00f76a..baaa712c90 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -109,7 +109,6 @@ export const claude37Sonnet = modelRef({ version: 'claude-3-7-sonnet-latest', }); - export const claude3Opus = modelRef({ name: 'claude-3-opus', namespace: 'anthropic', @@ -128,7 +127,6 @@ export const claude3Opus = modelRef({ version: 'claude-3-opus-20240229', }); - export const claude3Haiku = modelRef({ name: 'claude-3-haiku', namespace: 'anthropic', From 81edfdc433b3aae44650110117a4bd163fdeacfa Mon Sep 17 00:00:00 2001 From: HassanBahati Date: Wed, 29 Oct 2025 16:46:12 +0300 Subject: [PATCH 07/51] chore(js/plugins/anthropic): add supported anthropic models --- js/plugins/anthropic/src/claude.ts | 57 ++++++++++++++++ js/plugins/anthropic/src/index.ts | 6 ++ js/plugins/anthropic/tests/converters_test.ts | 68 ------------------- js/plugins/anthropic/tests/index_test.ts | 6 +- .../anthropic/tests/integration_test.ts | 6 +- 5 files changed, 69 insertions(+), 74 deletions(-) diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index baaa712c90..864103a379 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -181,6 +181,60 @@ export const claude35Haiku = modelRef({ version: 'claude-3-5-haiku-latest', }); +export const claude45Sonnet = modelRef({ + name: 'claude-4-5-sonnet', + namespace: 'anthropic', + info: { + versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-latest'], + label: 'Anthropic - Claude 4.5 Sonnet', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-sonnet-4-5-latest', +}); + +export const claude45Haiku = modelRef({ + name: 'claude-4-5-haiku', + namespace: 'anthropic', + info: { + versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5-latest'], + label: 'Anthropic - Claude 4.5 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-haiku-4-5-latest', +}); + +export const claude41Opus = modelRef({ + name: 'claude-4-1-opus', + namespace: 'anthropic', + info: { + versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1-latest'], + label: 'Anthropic - Claude 4.1 Opus', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-opus-4-1-latest', +}); + export const SUPPORTED_CLAUDE_MODELS: Record< string, ModelReference @@ -191,6 +245,9 @@ export const SUPPORTED_CLAUDE_MODELS: Record< 'claude-3-5-haiku': claude35Haiku, 'claude-4-sonnet': claude4Sonnet, 'claude-4-opus': claude4Opus, + 'claude-4-5-sonnet': claude45Sonnet, + 'claude-4-5-haiku': claude45Haiku, + 'claude-4-1-opus': claude41Opus, }; /** diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 4b455dcc16..4868fb3d73 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -30,6 +30,9 @@ import { claude37Sonnet, claude3Haiku, claude3Opus, + claude41Opus, + claude45Haiku, + claude45Sonnet, claude4Opus, claude4Sonnet, claudeModel, @@ -40,6 +43,9 @@ export { claude37Sonnet, claude3Haiku, claude3Opus, + claude41Opus, + claude45Haiku, + claude45Sonnet, claude4Opus, claude4Sonnet, }; diff --git a/js/plugins/anthropic/tests/converters_test.ts b/js/plugins/anthropic/tests/converters_test.ts index 045ce84e47..7c3eb7de74 100644 --- a/js/plugins/anthropic/tests/converters_test.ts +++ b/js/plugins/anthropic/tests/converters_test.ts @@ -664,40 +664,6 @@ describe('toAnthropicRequestBody', () => { genkitRequest: GenerateRequest; expectedOutput: MessageCreateParams; }[] = [ - { - should: '(claude-3-5-sonnet) handles request with text messages', - modelName: 'claude-3-5-sonnet', - genkitRequest: { - messages: [ - { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, - ], - output: { format: 'text' }, - config: { - metadata: { - user_id: 'exampleUser123', - }, - }, - }, - expectedOutput: { - max_tokens: 4096, - messages: [ - { - content: [ - { - text: 'Tell a joke about dogs.', - type: 'text', - citations: null, - }, - ], - role: 'user', - }, - ], - model: 'claude-3-5-sonnet-latest', - metadata: { - user_id: 'exampleUser123', - }, - }, - }, { should: '(claude-3-5-haiku) handles request with text messages', modelName: 'claude-3-5-haiku', @@ -800,40 +766,6 @@ describe('toAnthropicRequestBody', () => { }, }, }, - { - should: '(claude-3-sonnet) handles request with text messages', - modelName: 'claude-3-sonnet', - genkitRequest: { - messages: [ - { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, - ], - output: { format: 'text' }, - config: { - metadata: { - user_id: 'exampleUser123', - }, - }, - }, - expectedOutput: { - max_tokens: 4096, - messages: [ - { - content: [ - { - text: 'Tell a joke about dogs.', - type: 'text', - citations: null, - }, - ], - role: 'user', - }, - ], - model: 'claude-3-sonnet-20240229', - metadata: { - user_id: 'exampleUser123', - }, - }, - }, ]; for (const test of testCases) { it(test.should, () => { diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index 7fec2ebcd0..a8cf06dc88 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -29,10 +29,10 @@ describe('Anthropic Plugin', () => { process.env.ANTHROPIC_API_KEY = 'test-api-key'; // Import after mocking is set up - const indexModule = await import('../src/index.js'); - anthropic = indexModule.anthropic; + const indexModule = await import('../lib/index.js'); + anthropic = indexModule.default; - const claudeModule = await import('../src/claude.js'); + const claudeModule = await import('../lib/claude.js'); SUPPORTED_CLAUDE_MODELS = claudeModule.SUPPORTED_CLAUDE_MODELS; }); diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index 939e8cf9d6..4b38ecc2ae 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -28,15 +28,15 @@ describe('Anthropic Integration', () => { process.env.ANTHROPIC_API_KEY = 'test-api-key'; // Import and initialize after mocking - const { anthropic } = await import('../src/index.js'); + const indexModule = await import('../lib/index.js'); ai = genkit({ - plugins: [anthropic()], + plugins: [indexModule.default()], }); }); it('should successfully generate a response', async () => { const result = await ai.generate({ - model: 'anthropic/claude-3-5-sonnet', + model: 'anthropic/claude-3-5-haiku', prompt: 'Hello', }); From 1a6ec3967339fe21eee0c92ec8bc4a72a1107de1 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 3 Nov 2025 12:01:14 +0000 Subject: [PATCH 08/51] refactor: code improvements --- js/plugins/anthropic/src/claude.ts | 37 ++++++++++++++++++++--------- js/plugins/anthropic/src/index.ts | 6 +---- js/plugins/anthropic/src/types.ts | 38 ++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 16 deletions(-) create mode 100644 js/plugins/anthropic/src/types.ts diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index 864103a379..d16f3d6678 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -27,7 +27,7 @@ import type { Tool, ToolResultBlockParam, ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages.mjs'; +} from '@anthropic-ai/sdk/resources/messages'; import type { GenerateRequest, GenerateResponseData, @@ -51,6 +51,8 @@ import type { import { modelRef } from 'genkit/model'; import { model } from 'genkit/plugin'; +import { MediaType, type Media } from './types.js'; + export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ tool_choice: z .union([ @@ -269,11 +271,6 @@ export function toAnthropicRole( } } -interface Media { - url: string; - contentType?: string; -} - const isMediaObject = (obj: unknown): obj is Media => typeof obj === 'object' && obj !== null && @@ -305,6 +302,8 @@ export function toAnthropicToolResponseContent( )}.` ); } + + // Check if the output is a media object or a string const isMedia = isMediaObject(part.toolResponse?.output); const isString = typeof part.toolResponse?.output === 'string'; let base64Data; @@ -315,15 +314,23 @@ export function toAnthropicToolResponseContent( } else if (isString) { base64Data = extractDataFromBase64Url(part.toolResponse?.output as string); } + + // Resolve the media type + const resolvedMediaType: string | undefined = + (part.toolResponse?.output as Media)?.contentType ?? + base64Data?.contentType; + if (!resolvedMediaType || !(resolvedMediaType in MediaType)) { + throw new Error(`Invalid media type: ${resolvedMediaType}`); + } + const mediaTypeValue: MediaType = resolvedMediaType as MediaType; + return base64Data ? { type: 'image', source: { type: 'base64', data: base64Data.data, - media_type: - (part.toolResponse?.output as Media)?.contentType ?? - base64Data.contentType, + media_type: mediaTypeValue, }, } : { @@ -357,13 +364,21 @@ export function toAnthropicMessageContent( )}.` ); } + + // Resolve the media type + const resolvedMediaType: string | undefined = + part.media.contentType ?? contentType; + if (!resolvedMediaType || !(resolvedMediaType in MediaType)) { + throw new Error(`Invalid media type: ${resolvedMediaType}`); + } + const mediaTypeValue: MediaType = resolvedMediaType as MediaType; + return { type: 'image', source: { type: 'base64', data, - // @ts-expect-error TODO: improve these types - media_type: part.media.contentType ?? contentType, + media_type: mediaTypeValue, }, }; } diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 4868fb3d73..0f57baea20 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -37,6 +37,7 @@ import { claude4Sonnet, claudeModel, } from './claude.js'; +import { PluginOptions } from './types.js'; export { claude35Haiku, @@ -50,11 +51,6 @@ export { claude4Sonnet, }; -export interface PluginOptions { - apiKey?: string; - cacheSystemPrompt?: boolean; -} - async function list(client: Anthropic): Promise { const clientModels = (await client.models.list()).data; const result: ActionMetadata[] = []; diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts new file mode 100644 index 0000000000..15130822a1 --- /dev/null +++ b/js/plugins/anthropic/src/types.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. + */ + +/** + * Plugin configuration options for the Anthropic plugin. + */ +export interface PluginOptions { + apiKey?: string; + cacheSystemPrompt?: boolean; +} + +/** + * Media object representation with URL and optional content type. + */ +export interface Media { + url: string; + contentType?: string; +} + +export enum MediaType { + JPEG = 'image/jpeg', + PNG = 'image/png', + GIF = 'image/gif', + WEBP = 'image/webp', +} From 1e4246fdb82ec3259c71517b974fdcad29ae6cdc Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 3 Nov 2025 12:18:05 +0000 Subject: [PATCH 09/51] fix(anthropic): remove todo comment as unnecessary and replace deprecated type --- .github/workflows/anthropic-plugin-tests.yml | 56 ++++++++++ js/plugins/anthropic/package.json | 2 +- js/plugins/anthropic/src/claude.ts | 100 ++++++------------ js/plugins/anthropic/src/index.ts | 5 - js/plugins/anthropic/tests/converters_test.ts | 34 ------ .../anthropic/tests/integration_test.ts | 4 +- 6 files changed, 94 insertions(+), 107 deletions(-) create mode 100644 .github/workflows/anthropic-plugin-tests.yml diff --git a/.github/workflows/anthropic-plugin-tests.yml b/.github/workflows/anthropic-plugin-tests.yml new file mode 100644 index 0000000000..93507d5eb3 --- /dev/null +++ b/.github/workflows/anthropic-plugin-tests.yml @@ -0,0 +1,56 @@ +# 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. +# +# SPDX-License-Identifier: Apache-2.0 + +name: Anthropic Plugin Tests + +on: pull_request + +env: + GITHUB_PULL_REQUEST_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + GITHUB_PULL_REQUEST_BASE_SHA: ${{ github.event.pull_request.base.sha }} + +jobs: + test-anthropic-plugin: + name: Run Anthropic Plugin Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: pnpm/action-setup@v3 + - name: Set up node v20 + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: pnpm + - name: Install dependencies + run: | + cd js + pnpm install + - name: Build core dependencies + run: | + cd js + pnpm -r --workspace-concurrency 1 -F core -F ai build + - name: Build genkit + run: | + cd js + pnpm -F genkit build + - name: Build Anthropic plugin + run: | + cd js/plugins/anthropic + pnpm build + - name: Run Anthropic plugin tests + run: | + cd js/plugins/anthropic + pnpm test diff --git a/js/plugins/anthropic/package.json b/js/plugins/anthropic/package.json index 6a69d6849c..12040d0919 100644 --- a/js/plugins/anthropic/package.json +++ b/js/plugins/anthropic/package.json @@ -62,7 +62,7 @@ "build:clean": "rimraf ./lib", "build": "npm-run-all build:clean check compile", "build:watch": "tsup-node --watch", - "test": "tsx --test --experimental-test-module-mocks ./tests/**/*_test.ts", + "test": "tsx --test --experimental-test-module-mocks tests/*_test.ts", "test:file": "tsx --test --experimental-test-module-mocks", "test:coverage": "tsx --test --experimental-test-module-mocks --experimental-test-coverage --test-coverage-include='src/**/*.ts' ./tests/**/*_test.ts" } diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index d16f3d6678..0eb1f8f2ce 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -33,6 +33,7 @@ import type { GenerateResponseData, MessageData, ModelReference, + ModelResponseData, Part, Role, StreamingCallback, @@ -43,7 +44,6 @@ import { z, } from 'genkit'; import type { - CandidateData, GenerateResponseChunkData, ModelAction, ToolDefinition, @@ -93,42 +93,6 @@ export const claude4Sonnet = modelRef({ version: 'claude-sonnet-4-20250514', }); -export const claude37Sonnet = modelRef({ - name: 'claude-3-7-sonnet', - namespace: 'anthropic', - info: { - versions: ['claude-3-7-sonnet-20250219', 'claude-3-7-sonnet-latest'], - label: 'Anthropic - Claude 3.7 Sonnet', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-3-7-sonnet-latest', -}); - -export const claude3Opus = modelRef({ - name: 'claude-3-opus', - namespace: 'anthropic', - info: { - versions: ['claude-3-opus-20240229'], - label: 'Anthropic - Claude 3 Opus', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-3-opus-20240229', -}); - export const claude3Haiku = modelRef({ name: 'claude-3-haiku', namespace: 'anthropic', @@ -241,8 +205,6 @@ export const SUPPORTED_CLAUDE_MODELS: Record< string, ModelReference > = { - 'claude-3-7-sonnet': claude37Sonnet, - 'claude-3-opus': claude3Opus, 'claude-3-haiku': claude3Haiku, 'claude-3-5-haiku': claude35Haiku, 'claude-4-sonnet': claude4Sonnet, @@ -315,30 +277,36 @@ export function toAnthropicToolResponseContent( base64Data = extractDataFromBase64Url(part.toolResponse?.output as string); } - // Resolve the media type - const resolvedMediaType: string | undefined = - (part.toolResponse?.output as Media)?.contentType ?? - base64Data?.contentType; - if (!resolvedMediaType || !(resolvedMediaType in MediaType)) { - throw new Error(`Invalid media type: ${resolvedMediaType}`); + // Handle media content + if (base64Data) { + const resolvedMediaType: string | undefined = + (part.toolResponse?.output as Media)?.contentType ?? + base64Data?.contentType; + if ( + !resolvedMediaType || + !Object.values(MediaType).includes(resolvedMediaType as MediaType) + ) { + throw new Error(`Invalid media type: ${resolvedMediaType}`); + } + const mediaTypeValue: MediaType = resolvedMediaType as MediaType; + + return { + type: 'image', + source: { + type: 'base64', + data: base64Data.data, + media_type: mediaTypeValue, + }, + }; } - const mediaTypeValue: MediaType = resolvedMediaType as MediaType; - return base64Data - ? { - type: 'image', - source: { - type: 'base64', - data: base64Data.data, - media_type: mediaTypeValue, - }, - } - : { - type: 'text', - text: isString - ? (part.toolResponse?.output as string) - : JSON.stringify(part.toolResponse?.output), - }; + // Handle text content + return { + type: 'text', + text: isString + ? (part.toolResponse?.output as string) + : JSON.stringify(part.toolResponse?.output), + }; } /** @@ -365,12 +333,15 @@ export function toAnthropicMessageContent( ); } - // Resolve the media type + // Resolve and validate the media type const resolvedMediaType: string | undefined = part.media.contentType ?? contentType; - if (!resolvedMediaType || !(resolvedMediaType in MediaType)) { + if (!resolvedMediaType) { throw new Error(`Invalid media type: ${resolvedMediaType}`); } + if (!Object.values(MediaType).includes(resolvedMediaType as MediaType)) { + throw new Error(`Unsupported media type: ${resolvedMediaType}`); + } const mediaTypeValue: MediaType = resolvedMediaType as MediaType; return { @@ -503,8 +474,7 @@ export function fromAnthropicContentBlockChunk( export function fromAnthropicStopReason( reason: Message['stop_reason'] - // TODO: CandidateData is deprecated -): CandidateData['finishReason'] { +): ModelResponseData['finishReason'] { switch (reason) { case 'max_tokens': return 'length'; diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 0f57baea20..68473405d7 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -27,9 +27,7 @@ import { ActionType } from 'genkit/registry'; import { SUPPORTED_CLAUDE_MODELS, claude35Haiku, - claude37Sonnet, claude3Haiku, - claude3Opus, claude41Opus, claude45Haiku, claude45Sonnet, @@ -41,9 +39,7 @@ import { PluginOptions } from './types.js'; export { claude35Haiku, - claude37Sonnet, claude3Haiku, - claude3Opus, claude41Opus, claude45Haiku, claude45Sonnet, @@ -102,7 +98,6 @@ async function list(client: Anthropic): Promise { * }); * ``` */ -// TODO: add support for voyage embeddings and tool use (both not documented well in docs.anthropic.com) export const anthropic = (options?: PluginOptions): GenkitPluginV2 => { let apiKey = options?.apiKey || process.env.ANTHROPIC_API_KEY; if (!apiKey) { diff --git a/js/plugins/anthropic/tests/converters_test.ts b/js/plugins/anthropic/tests/converters_test.ts index 7c3eb7de74..bafa23ce36 100644 --- a/js/plugins/anthropic/tests/converters_test.ts +++ b/js/plugins/anthropic/tests/converters_test.ts @@ -698,40 +698,6 @@ describe('toAnthropicRequestBody', () => { }, }, }, - { - should: '(claude-3-opus) handles request with text messages', - modelName: 'claude-3-opus', - genkitRequest: { - messages: [ - { role: 'user', content: [{ text: 'Tell a joke about dogs.' }] }, - ], - output: { format: 'text' }, - config: { - metadata: { - user_id: 'exampleUser123', - }, - }, - }, - expectedOutput: { - max_tokens: 4096, - messages: [ - { - content: [ - { - text: 'Tell a joke about dogs.', - type: 'text', - citations: null, - }, - ], - role: 'user', - }, - ], - model: 'claude-3-opus-20240229', - metadata: { - user_id: 'exampleUser123', - }, - }, - }, { should: '(claude-3-haiku) handles request with text messages', modelName: 'claude-3-haiku', diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index 4b38ecc2ae..6e0ea51edd 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -28,9 +28,9 @@ describe('Anthropic Integration', () => { process.env.ANTHROPIC_API_KEY = 'test-api-key'; // Import and initialize after mocking - const indexModule = await import('../lib/index.js'); + const { anthropic } = await import('../src/index.ts'); ai = genkit({ - plugins: [indexModule.default()], + plugins: [anthropic()], }); }); From 885d52e055977a49e7d31e91cd0b96bdfeccedb8 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 4 Nov 2025 15:25:07 +0000 Subject: [PATCH 10/51] refactor: allow hidden test client injection --- js/plugins/anthropic/src/index.ts | 39 +++++-- js/plugins/anthropic/src/types.ts | 17 +++ js/plugins/anthropic/tests/index_test.ts | 27 ++--- .../anthropic/tests/integration_test.ts | 23 ++-- .../tests/mocks/setup-anthropic-mock.ts | 104 ------------------ 5 files changed, 61 insertions(+), 149 deletions(-) delete mode 100644 js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 68473405d7..abc1465596 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -35,7 +35,7 @@ import { claude4Sonnet, claudeModel, } from './claude.js'; -import { PluginOptions } from './types.js'; +import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; export { claude35Haiku, @@ -71,6 +71,31 @@ async function list(client: Anthropic): Promise { return result; } +/** + * Gets or creates an Anthropic client instance. + * Supports test client injection for internal testing. + */ +function getAnthropicClient(options?: PluginOptions): Anthropic { + // Check for test client injection first (internal use only) + const internalOptions = options as InternalPluginOptions | undefined; + if (internalOptions?.[__testClient]) { + return internalOptions[__testClient]; + } + + // Production path: create real client + const apiKey = options?.apiKey || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error( + 'Please pass in the API key or set the ANTHROPIC_API_KEY environment variable' + ); + } + const defaultHeaders: Record = {}; + if (options?.cacheSystemPrompt == true) { + defaultHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31'; + } + return new Anthropic({ apiKey, defaultHeaders }); +} + /** * This module provides an interface to the Anthropic AI models through the Genkit plugin system. * It allows users to interact with various Claude models by providing an API key and optional configuration. @@ -99,17 +124,7 @@ async function list(client: Anthropic): Promise { * ``` */ export const anthropic = (options?: PluginOptions): GenkitPluginV2 => { - let apiKey = options?.apiKey || process.env.ANTHROPIC_API_KEY; - if (!apiKey) { - throw new Error( - 'Please pass in the API key or set the ANTHROPIC_API_KEY environment variable' - ); - } - let defaultHeaders = {}; - if (options?.cacheSystemPrompt == true) { - defaultHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31'; - } - const client = new Anthropic({ apiKey, defaultHeaders }); + const client = getAnthropicClient(options); let listActionsCache: ActionMetadata[] | null = null; diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 15130822a1..4d23468685 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -14,6 +14,15 @@ * limitations under the License. */ +import type Anthropic from '@anthropic-ai/sdk'; + +/** + * Internal symbol for dependency injection in tests. + * Not part of the public API. + * @internal + */ +export const __testClient = Symbol('testClient'); + /** * Plugin configuration options for the Anthropic plugin. */ @@ -22,6 +31,14 @@ export interface PluginOptions { cacheSystemPrompt?: boolean; } +/** + * Internal plugin options that include test client injection. + * @internal + */ +export interface InternalPluginOptions extends PluginOptions { + [__testClient]?: Anthropic; +} + /** * Media object representation with URL and optional content type. */ diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index a8cf06dc88..acf24b1d42 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -16,29 +16,18 @@ import * as assert from 'assert'; import { genkit } from 'genkit'; -import { before, describe, it } from 'node:test'; -import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; - -setupAnthropicMock(); +import { describe, it } from 'node:test'; +import { SUPPORTED_CLAUDE_MODELS } from '../src/claude.js'; +import anthropic from '../src/index.js'; +import { PluginOptions, __testClient } from '../src/types.js'; +import { createMockAnthropicClient } from './mocks/anthropic-client.js'; describe('Anthropic Plugin', () => { - let anthropic: any; - let SUPPORTED_CLAUDE_MODELS: any; - - before(async () => { - process.env.ANTHROPIC_API_KEY = 'test-api-key'; - - // Import after mocking is set up - const indexModule = await import('../lib/index.js'); - anthropic = indexModule.default; - - const claudeModule = await import('../lib/claude.js'); - SUPPORTED_CLAUDE_MODELS = claudeModule.SUPPORTED_CLAUDE_MODELS; - }); - it('should register all supported Claude models', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ - plugins: [anthropic()], + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], }); for (const modelName of Object.keys(SUPPORTED_CLAUDE_MODELS)) { diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index 6e0ea51edd..c4461570d4 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -15,23 +15,18 @@ */ import * as assert from 'assert'; -import { genkit, type Genkit } from 'genkit'; -import { before, describe, it } from 'node:test'; -import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; +import { genkit } from 'genkit'; +import { describe, it } from 'node:test'; +import { anthropic } from '../src/index.js'; +import { __testClient } from '../src/types.js'; +import { createMockAnthropicClient } from './mocks/anthropic-client.js'; -setupAnthropicMock(); +import { PluginOptions } from '../src/types.js'; describe('Anthropic Integration', () => { - let ai: Genkit; - - before(async () => { - process.env.ANTHROPIC_API_KEY = 'test-api-key'; - - // Import and initialize after mocking - const { anthropic } = await import('../src/index.ts'); - ai = genkit({ - plugins: [anthropic()], - }); + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], }); it('should successfully generate a response', async () => { diff --git a/js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts b/js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts deleted file mode 100644 index 18e5c5190f..0000000000 --- a/js/plugins/anthropic/tests/mocks/setup-anthropic-mock.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * 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 type { Message } from '@anthropic-ai/sdk/resources/messages.mjs'; -import { mock } from 'node:test'; - -export interface SetupAnthropicMockOptions { - messageResponse?: Partial; -} - -export interface MockAnthropicClient { - messages: { - create: any; - stream: any; - }; - models: { - list: any; - }; -} - -export interface SetupAnthropicMockResult { - mockClient: MockAnthropicClient; - mockResponse: Message; -} - -/** - * Sets up mocking for the Anthropic SDK module. - * Must be called at the module level (before describe blocks). - * - * @param options - Configuration for mock responses - * @returns Mock client and response for assertions - * - * @example - * ```typescript - * import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; - * - * const { mockClient } = setupAnthropicMock({ - * messageResponse: { content: [{ type: 'text', text: 'Custom response' }] } - * }); - * - * describe('My Test Suite', () => { - * // tests here - * }); - * ``` - */ -export function setupAnthropicMock( - options: SetupAnthropicMockOptions = {} -): SetupAnthropicMockResult { - const mockResponse: Message = { - id: 'msg_test123', - type: 'message', - role: 'assistant', - model: 'claude-3-5-sonnet-20241022', - content: [ - { - type: 'text', - text: 'Hello! How can I help you today?', - citations: null, - }, - ], - stop_reason: 'end_turn', - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0, - }, - ...options.messageResponse, - }; - - const mockClient = { - messages: { - create: mock.fn(async () => mockResponse), - stream: mock.fn(), - }, - models: { - list: mock.fn(), - }, - }; - - const MockAnthropic = function (this: any, opts: any) { - return mockClient; - } as any; - - mock.module('@anthropic-ai/sdk', { - defaultExport: MockAnthropic, - }); - - return { mockClient, mockResponse }; -} From 381c74537414edf38bdaaabc2dd6942dd461bf73 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 4 Nov 2025 16:27:03 +0000 Subject: [PATCH 11/51] feat: add pdf support and complete some test todos --- js/plugins/anthropic/src/claude.ts | 142 +++++- js/plugins/anthropic/src/index.ts | 5 +- js/plugins/anthropic/tests/converters_test.ts | 417 +++++++++++++++++- js/plugins/anthropic/tests/index_test.ts | 142 +++++- .../anthropic/tests/integration_test.ts | 236 +++++++++- .../anthropic/tests/mocks/anthropic-client.ts | 64 ++- 6 files changed, 946 insertions(+), 60 deletions(-) diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index 0eb1f8f2ce..967973fb4b 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -17,6 +17,7 @@ import type Anthropic from '@anthropic-ai/sdk'; import type { ContentBlock, + DocumentBlockParam, ImageBlockParam, Message, MessageCreateParams, @@ -53,6 +54,11 @@ import { model } from 'genkit/plugin'; import { MediaType, type Media } from './types.js'; +/** + * Default maximum output tokens for Claude models when not specified in the request. + */ +const DEFAULT_MAX_OUTPUT_TOKENS = 4096; + export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ tool_choice: z .union([ @@ -239,6 +245,14 @@ const isMediaObject = (obj: unknown): obj is Media => 'url' in obj && typeof (obj as Media).url === 'string'; +/** + * Checks if a URL is a data URL (starts with 'data:'). + * This follows the Google GenAI plugin pattern for distinguishing inline data from file references. + */ +const isDataUrl = (url: string): boolean => { + return url.startsWith('data:'); +}; + const extractDataFromBase64Url = ( url: string ): { data: string; contentType: string } | null => { @@ -314,8 +328,15 @@ export function toAnthropicToolResponseContent( */ export function toAnthropicMessageContent( part: Part -): TextBlock | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam { +): + | TextBlock + | ImageBlockParam + | DocumentBlockParam + | ToolUseBlockParam + | ToolResultBlockParam { if (part.text) { + // Anthropic SDK expects citations field to be explicitly set to null + // when not provided (tests confirm this pattern is correct) return { type: 'text', text: part.text, @@ -323,6 +344,52 @@ export function toAnthropicMessageContent( }; } if (part.media) { + const resolvedContentType = part.media.contentType; + + // Check if this is a PDF document + if (resolvedContentType === 'application/pdf') { + const url = part.media.url; + + if (isDataUrl(url)) { + // Extract base64 data and MIME type from data URL + const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); + if (!base64Match) { + throw new Error( + `Invalid PDF data URL format: ${url.substring(0, 50)}...` + ); + } + + const extractedContentType = base64Match[1]; + const base64Data = base64Match[2]; + + // Verify the extracted type matches PDF + if (extractedContentType !== 'application/pdf') { + throw new Error( + `PDF contentType mismatch: expected application/pdf, got ${extractedContentType}` + ); + } + + return { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: base64Data, + }, + }; + } else { + // File URL (HTTP/HTTPS/other) - contentType is already verified as 'application/pdf' + return { + type: 'document', + source: { + type: 'url', + url: url, + }, + }; + } + } + + // Handle non-PDF media (images) - existing logic const { data, contentType } = extractDataFromBase64Url(part.media.url) ?? {}; if (!data) { @@ -354,17 +421,31 @@ export function toAnthropicMessageContent( }; } if (part.toolRequest) { + if (!part.toolRequest.ref) { + throw new Error( + `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolRequest + )}` + ); + } return { type: 'tool_use', - id: part.toolRequest.ref!, + id: part.toolRequest.ref, name: part.toolRequest.name, input: part.toolRequest.input, }; } if (part.toolResponse) { + if (!part.toolResponse.ref) { + throw new Error( + `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolResponse + )}` + ); + } return { type: 'tool_result', - tool_use_id: part.toolResponse.ref!, + tool_use_id: part.toolResponse.ref, content: [toAnthropicToolResponseContent(part)], }; } @@ -440,7 +521,12 @@ function fromAnthropicContentBlock(contentBlock: ContentBlock): Part { } else if (contentBlock.type === 'redacted_thinking') { return { text: contentBlock.data }; } else { - // Handle other content block types + // Handle unexpected content block types + // Log warning for debugging, but return empty text to avoid breaking the flow + const unknownType = (contentBlock as { type: string }).type; + console.warn( + `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` + ); return { text: '' }; } } @@ -518,7 +604,7 @@ export function fromAnthropicResponse(response: Message): GenerateResponseData { * @param stream Whether to stream the response. * @param cacheSystemPrompt Whether to cache the system prompt. * @returns The converted Anthropic API request body. - * @throws An error if the specified model is not supported or if an unsupported output format is requested. + * @throws An error if an unsupported output format is requested. */ export function toAnthropicRequestBody( modelName: string, @@ -526,10 +612,11 @@ export function toAnthropicRequestBody( stream?: boolean, cacheSystemPrompt?: boolean ): MessageCreateParams { + // Use supported model ref if available for version mapping, otherwise use modelName directly const model = SUPPORTED_CLAUDE_MODELS[modelName]; - if (!model) throw new Error(`Unsupported model: ${modelName}`); const { system, messages } = toAnthropicMessages(request.messages); - const mappedModelName = request.config?.version ?? model.version ?? modelName; + const mappedModelName = + request.config?.version ?? model?.version ?? modelName; const body: MessageCreateParams = { system: cacheSystemPrompt && system @@ -543,7 +630,7 @@ export function toAnthropicRequestBody( : system, messages, tools: request.tools?.map(toAnthropicTool), - max_tokens: request.config?.maxOutputTokens ?? 4096, + max_tokens: request.config?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, model: mappedModelName, top_k: request.config?.topK, top_p: request.config?.topP, @@ -559,11 +646,15 @@ export function toAnthropicRequestBody( `Only text output format is supported for Claude models currently` ); } - for (const key in body) { - if (!body[key] || (Array.isArray(body[key]) && !body[key].length)) - delete body[key]; - } - return body; + // Remove undefined, null, and empty array values using a type-safe approach + const cleanedBody = Object.fromEntries( + Object.entries(body).filter(([_, value]) => { + if (value === undefined || value === null) return false; + if (Array.isArray(value) && value.length === 0) return false; + return true; + }) + ) as MessageCreateParams; + return cleanedBody; } /** @@ -619,22 +710,41 @@ export function claudeRunner( }; } +/** + * Generic Claude model info for unknown/unsupported models. + * Used when a model name is not in SUPPORTED_CLAUDE_MODELS. + */ +const GENERIC_CLAUDE_MODEL_INFO = { + versions: [], + label: 'Anthropic - Claude', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, +}; + /** * Defines a Claude model with the given name and Anthropic client. + * accepts any model name and lets the APIvalidate it. If the model is in SUPPORTED_CLAUDE_MODELS, uses that modelRef + * for better defaults; otherwise creates a generic model reference. */ export function claudeModel( name: string, client: Anthropic, cacheSystemPrompt?: boolean ): ModelAction { + // Use supported model ref if available, otherwise create generic model ref const modelRef = SUPPORTED_CLAUDE_MODELS[name]; - if (!modelRef) throw new Error(`Unsupported model: ${name}`); + const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO; return model( { name: `anthropic/${name}`, - ...modelRef.info, - configSchema: modelRef.configSchema, + ...modelInfo, + configSchema: AnthropicConfigSchema, }, claudeRunner(name, client, cacheSystemPrompt) ); diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index abc1465596..d34841d3ac 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -140,7 +140,10 @@ export const anthropic = (options?: PluginOptions): GenkitPluginV2 => { }, resolve: (actionType: ActionType, name: string) => { if (actionType === 'model') { - return claudeModel(name, client); + // Strip the 'anthropic/' namespace prefix if present + const modelName = name.startsWith('anthropic/') ? name.slice(10) : name; + // Follow Google GenAI pattern: accept any model name and let the API validate it + return claudeModel(modelName, client, options?.cacheSystemPrompt); } return undefined; }, diff --git a/js/plugins/anthropic/tests/converters_test.ts b/js/plugins/anthropic/tests/converters_test.ts index bafa23ce36..0d91d91b37 100644 --- a/js/plugins/anthropic/tests/converters_test.ts +++ b/js/plugins/anthropic/tests/converters_test.ts @@ -46,7 +46,12 @@ import { toAnthropicTool, toAnthropicToolResponseContent, } from '../src/claude.js'; -import { createMockAnthropicClient } from './mocks/anthropic-client.js'; +import { + createMockAnthropicClient, + mockContentBlockStart, + mockTextChunk, + mockToolUseChunk, +} from './mocks/anthropic-client.js'; describe('toAnthropicRole', () => { const testCases: { @@ -125,6 +130,56 @@ describe('toAnthropicMessageContent', () => { /Unsupported genkit part fields encountered for current message role: {"fake":"part"}/ ); }); + + it('should throw if media with file URL cannot be processed as image', () => { + // File URLs without contentType cannot be processed (they're not data URLs) + // This will fall through to image handling which requires data URLs + assert.throws( + () => + toAnthropicMessageContent({ + media: { + url: 'https://example.com/document.pdf', + // contentType missing - won't be recognized as PDF + }, + }), + /Invalid genkit part media provided to toAnthropicMessageContent/ + ); + }); + + it('should handle PDF with base64 data URL correctly', () => { + const result = toAnthropicMessageContent({ + media: { + url: 'data:application/pdf;base64,JVBERi0xLjQKJ', + contentType: 'application/pdf', + }, + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: 'JVBERi0xLjQKJ', + }, + }); + }); + + it('should handle PDF with HTTP/HTTPS URL correctly', () => { + const result = toAnthropicMessageContent({ + media: { + url: 'https://example.com/document.pdf', + contentType: 'application/pdf', + }, + }); + + assert.deepStrictEqual(result, { + type: 'document', + source: { + type: 'url', + url: 'https://example.com/document.pdf', + }, + }); + }); }); describe('toAnthropicMessages', () => { @@ -397,6 +452,127 @@ describe('toAnthropicMessages', () => { system: undefined, }, }, + { + should: 'should transform PDF with base64 data URL correctly', + inputMessages: [ + { + role: 'user', + content: [ + { + media: { + url: 'data:application/pdf;base64,JVBERi0xLjQKJ', + contentType: 'application/pdf', + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + content: [ + { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: 'JVBERi0xLjQKJ', + }, + }, + ], + role: 'user', + }, + ], + system: undefined, + }, + }, + { + should: 'should transform PDF with HTTP/HTTPS URL correctly', + inputMessages: [ + { + role: 'user', + content: [ + { + media: { + url: 'https://example.com/document.pdf', + contentType: 'application/pdf', + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + content: [ + { + type: 'document', + source: { + type: 'url', + url: 'https://example.com/document.pdf', + }, + }, + ], + role: 'user', + }, + ], + system: undefined, + }, + }, + { + should: 'should transform PDF alongside text and images correctly', + inputMessages: [ + { + role: 'user', + content: [ + { text: 'Analyze this PDF and image:' }, + { + media: { + url: 'data:application/pdf;base64,JVBERi0xLjQKJ', + contentType: 'application/pdf', + }, + }, + { + media: { + url: 'data:image/png;base64,R0lGODlhAQABAAAAACw=', + contentType: 'image/png', + }, + }, + ], + }, + ], + expectedOutput: { + messages: [ + { + content: [ + { + text: 'Analyze this PDF and image:', + type: 'text', + citations: null, + }, + { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: 'JVBERi0xLjQKJ', + }, + }, + { + source: { + type: 'base64', + data: 'R0lGODlhAQABAAAAACw=', + media_type: 'image/png', + }, + type: 'image', + }, + ], + role: 'user', + }, + ], + system: undefined, + }, + }, ]; for (const test of testCases) { @@ -743,14 +919,14 @@ describe('toAnthropicRequestBody', () => { }); } - it('should throw if model is not supported', () => { - assert.throws( - () => - toAnthropicRequestBody('fake-model', { - messages: [], - } as GenerateRequest), - /Unsupported model: fake-model/ - ); + it('should accept any model name and use it directly', () => { + // Following Google GenAI pattern: accept any model name, let API validate + const result = toAnthropicRequestBody('fake-model', { + messages: [], + } as GenerateRequest); + + // Should not throw, and should use the model name directly + assert.strictEqual(result.model, 'fake-model'); }); it('should throw if output format is not text', () => { @@ -830,6 +1006,7 @@ describe('claudeRunner', () => { { model: 'claude-3-5-haiku-latest', max_tokens: 4096, + stream: false, }, { signal: abortSignal, @@ -894,18 +1071,224 @@ describe('claudeModel', () => { assert.strictEqual(typeof modelAction, 'function'); }); - it('should throw for unsupported models', () => { - assert.throws( - () => claudeModel('unsupported-model', {} as Anthropic), - /Unsupported model: unsupported-model/ + it('should accept any model name and create a model action', () => { + // Following Google GenAI pattern: accept any model name, let API validate + const modelAction = claudeModel('unsupported-model', {} as Anthropic); + assert.ok(modelAction, 'Should create model action for any model name'); + assert.strictEqual(typeof modelAction, 'function'); + }); + + it('should handle streaming with multiple text chunks', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [ + mockContentBlockStart('Hello'), + mockTextChunk(' world'), + mockTextChunk('!'), + ], + messageResponse: { + content: [{ type: 'text', text: 'Hello world!', citations: null }], + usage: { + input_tokens: 5, + output_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }); + + const chunks: any[] = []; + const streamingCallback = mock.fn((chunk: any) => { + chunks.push(chunk); + }); + + const runner = claudeRunner('claude-3-5-haiku', mockClient); + const abortSignal = new AbortController().signal; + + const result = await runner( + { messages: [{ role: 'user', content: [{ text: 'Hi' }] }] }, + { streamingRequested: true, sendChunk: streamingCallback, abortSignal } + ); + + // Verify we received all the streaming chunks + assert.ok(chunks.length > 0, 'Should have received streaming chunks'); + assert.strictEqual(chunks.length, 3, 'Should have received 3 chunks'); + + // Verify the final result + assert.ok(result.candidates); + assert.strictEqual( + result.candidates[0].message.content[0].text, + 'Hello world!' + ); + assert.ok(result.usage); + assert.strictEqual(result.usage.inputTokens, 5); + assert.strictEqual(result.usage.outputTokens, 10); + }); + + it('should handle tool use in streaming mode', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [ + mockToolUseChunk('toolu_123', 'get_weather', { city: 'NYC' }), + ], + messageResponse: { + content: [ + { + type: 'tool_use', + id: 'toolu_123', + name: 'get_weather', + input: { city: 'NYC' }, + }, + ], + stop_reason: 'tool_use', + usage: { + input_tokens: 15, + output_tokens: 25, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }); + + const chunks: any[] = []; + const streamingCallback = mock.fn((chunk: any) => { + chunks.push(chunk); + }); + + const runner = claudeRunner('claude-3-5-haiku', mockClient); + const abortSignal = new AbortController().signal; + + const result = await runner( + { + messages: [ + { role: 'user', content: [{ text: 'What is the weather?' }] }, + ], + tools: [ + { + name: 'get_weather', + description: 'Get the weather for a city', + inputSchema: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + required: ['city'], + }, + }, + ], + }, + { streamingRequested: true, sendChunk: streamingCallback, abortSignal } + ); + + // Verify we received the tool use chunk + assert.ok(chunks.length > 0, 'Should have received chunks'); + + // Verify the final result contains tool use + assert.ok(result.candidates); + const toolRequest = result.candidates[0].message.content.find( + (p) => p.toolRequest ); + assert.ok(toolRequest, 'Should have a tool request'); + assert.strictEqual(toolRequest.toolRequest?.name, 'get_weather'); + assert.deepStrictEqual(toolRequest.toolRequest?.input, { city: 'NYC' }); }); - it.todo('should handle streaming with multiple text chunks'); + it('should handle streaming errors and partial responses', async () => { + const streamError = new Error('Network error during streaming'); + const mockClient = createMockAnthropicClient({ + streamChunks: [mockContentBlockStart('Hello'), mockTextChunk(' world')], + streamErrorAfterChunk: 1, // Throw error after first chunk + streamError: streamError, + messageResponse: { + content: [{ type: 'text', text: 'Hello world', citations: null }], + usage: { + input_tokens: 5, + output_tokens: 10, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }); + + const runner = claudeRunner('claude-3-5-haiku', mockClient); + const abortSignal = new AbortController().signal; + const chunks: any[] = []; + const sendChunk = (chunk: any) => { + chunks.push(chunk); + }; + + // Should throw error during streaming + await assert.rejects( + async () => { + await runner( + { messages: [{ role: 'user', content: [{ text: 'Hi' }] }] }, + { + streamingRequested: true, + sendChunk, + abortSignal, + } + ); + }, + (error: Error) => { + // Verify error is propagated + assert.strictEqual(error.message, 'Network error during streaming'); + // Verify we received at least one chunk before error + assert.ok( + chunks.length > 0, + 'Should have received some chunks before error' + ); + return true; + } + ); + }); - it.todo('should handle tool use in streaming mode'); + it('should handle abort signal during streaming', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [ + mockContentBlockStart('Hello'), + mockTextChunk(' world'), + mockTextChunk('!'), + ], + messageResponse: { + content: [{ type: 'text', text: 'Hello world!', citations: null }], + usage: { + input_tokens: 5, + output_tokens: 15, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }); - it.todo('should handle streaming errors and partial responses'); + const runner = claudeRunner('claude-3-5-haiku', mockClient); + const abortController = new AbortController(); + const chunks: any[] = []; + const sendChunk = (chunk: any) => { + chunks.push(chunk); + // Abort after first chunk + if (chunks.length === 1) { + abortController.abort(); + } + }; - it.todo('should handle abort signal during streaming'); + // Should throw AbortError when signal is aborted + await assert.rejects( + async () => { + await runner( + { messages: [{ role: 'user', content: [{ text: 'Hi' }] }] }, + { + streamingRequested: true, + sendChunk, + abortSignal: abortController.signal, + } + ); + }, + (error: Error) => { + // Verify abort error is thrown + assert.ok( + error.name === 'AbortError' || error.message.includes('AbortError'), + 'Should throw AbortError' + ); + return true; + } + ); + }); }); diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index acf24b1d42..df86c6c44a 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -39,13 +39,145 @@ describe('Anthropic Plugin', () => { } }); - it.todo('should throw error when API key is missing'); + it('should throw error when API key is missing', () => { + // Save original env var if it exists + const originalApiKey = process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_API_KEY; - it.todo('should use API key from environment variable'); + try { + assert.throws(() => { + anthropic({} as PluginOptions); + }, /Please pass in the API key or set the ANTHROPIC_API_KEY environment variable/); + } finally { + // Restore original env var + if (originalApiKey !== undefined) { + process.env.ANTHROPIC_API_KEY = originalApiKey; + } + } + }); + + it('should use API key from environment variable', () => { + // Save original env var if it exists + const originalApiKey = process.env.ANTHROPIC_API_KEY; + const testApiKey = 'test-api-key-from-env'; + + try { + // Set test API key + process.env.ANTHROPIC_API_KEY = testApiKey; + + // Plugin should initialize without throwing + const plugin = anthropic({} as PluginOptions); + assert.ok(plugin); + assert.strictEqual(plugin.name, 'anthropic'); + } finally { + // Restore original env var + if (originalApiKey !== undefined) { + process.env.ANTHROPIC_API_KEY = originalApiKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + } + }); + + it('should resolve models dynamically via resolve function', async () => { + const mockClient = createMockAnthropicClient(); + const plugin = anthropic({ [__testClient]: mockClient } as PluginOptions); + + assert.ok(plugin.resolve, 'Plugin should have resolve method'); + + // Test resolving a valid model + const validModel = plugin.resolve!('model', 'anthropic/claude-3-5-haiku'); + assert.ok(validModel, 'Should resolve valid model'); + assert.strictEqual(typeof validModel, 'function'); + + // Test resolving an unknown model name - should return a model action + // (following Google GenAI pattern: accept any model name, let API validate) + const unknownModel = plugin.resolve!( + 'model', + 'anthropic/unknown-model-xyz' + ); + assert.ok(unknownModel, 'Should resolve unknown model name'); + assert.strictEqual( + typeof unknownModel, + 'function', + 'Should return a model action' + ); + + // Test resolving with invalid action type (using 'tool' as invalid for this context) + const invalidActionType = plugin.resolve!( + 'tool', + 'anthropic/claude-3-5-haiku' + ); + assert.strictEqual( + invalidActionType, + undefined, + 'Should return undefined for invalid action type' + ); + }); - it.todo('should resolve models dynamically via resolve function'); + it('should list available models from API', async () => { + const mockClient = createMockAnthropicClient({ + modelList: [ + { id: 'claude-3-5-haiku-20241022', display_name: 'Claude 3.5 Haiku' }, + { id: 'claude-3-5-sonnet-20241022', display_name: 'Claude 3.5 Sonnet' }, + { id: 'claude-sonnet-4-20250514', display_name: 'Claude 4 Sonnet' }, + ], + }); + + const plugin = anthropic({ [__testClient]: mockClient } as PluginOptions); + assert.ok(plugin.list, 'Plugin should have list method'); + + const models = await plugin.list!(); + + assert.ok(Array.isArray(models), 'Should return an array'); + assert.ok(models.length > 0, 'Should return at least one model'); + + // Verify model structure + for (const model of models) { + assert.ok(model.name, 'Model should have a name'); + // ActionMetadata has name and other properties, but kind is not required + } + + // Verify mock was called + const listStub = mockClient.models.list as any; + assert.strictEqual( + listStub.mock.calls.length, + 1, + 'models.list should be called once' + ); + }); + + it('should cache list results on subsequent calls?', async () => { + const mockClient = createMockAnthropicClient({ + modelList: [ + { id: 'claude-3-5-haiku-20241022', display_name: 'Claude 3.5 Haiku' }, + ], + }); + + const plugin = anthropic({ [__testClient]: mockClient } as PluginOptions); + assert.ok(plugin.list, 'Plugin should have list method'); - it.todo('should list available models from API'); + // First call + const firstResult = await plugin.list!(); + assert.ok(firstResult, 'First call should return results'); - it.todo('should cache list results on subsequent calls?'); + // Second call + const secondResult = await plugin.list!(); + assert.ok(secondResult, 'Second call should return results'); + + // Verify both results are the same (reference equality for cache) + assert.strictEqual( + firstResult, + secondResult, + 'Results should be cached (same reference)' + ); + + // Verify models.list was only called once due to caching + const listStub = mockClient.models.list as any; + assert.strictEqual( + listStub.mock.calls.length, + 1, + 'models.list should only be called once due to caching' + ); + }); }); diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index c4461570d4..ec5c811295 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -15,21 +15,27 @@ */ import * as assert from 'assert'; -import { genkit } from 'genkit'; +import { genkit, z } from 'genkit'; import { describe, it } from 'node:test'; import { anthropic } from '../src/index.js'; import { __testClient } from '../src/types.js'; -import { createMockAnthropicClient } from './mocks/anthropic-client.js'; +import { + createMockAnthropicClient, + createMockAnthropicMessage, + mockContentBlockStart, + mockMessageWithToolUse, + mockTextChunk, +} from './mocks/anthropic-client.js'; import { PluginOptions } from '../src/types.js'; describe('Anthropic Integration', () => { - const mockClient = createMockAnthropicClient(); - const ai = genkit({ - plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], - }); - it('should successfully generate a response', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + const result = await ai.generate({ model: 'anthropic/claude-3-5-haiku', prompt: 'Hello', @@ -38,19 +44,217 @@ describe('Anthropic Integration', () => { assert.strictEqual(result.text, 'Hello! How can I help you today?'); }); - it.todo( - 'should handle tool calling workflow (call tool, receive result, generate final response)' - ); + it('should handle tool calling workflow (call tool, receive result, generate final response)', async () => { + const mockClient = createMockAnthropicClient({ + sequentialResponses: [ + // First response: tool use request + mockMessageWithToolUse('get_weather', { city: 'NYC' }), + // Second response: final text after tool result + createMockAnthropicMessage({ + text: 'The weather in NYC is sunny, 72°F', + }), + ], + }); + + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + // Define the tool + ai.defineTool( + { + name: 'get_weather', + description: 'Get the weather for a city', + inputSchema: z.object({ + city: z.string(), + }), + }, + async (input: { city: string }) => { + return `The weather in ${input.city} is sunny, 72°F`; + } + ); + + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'What is the weather in NYC?', + tools: ['get_weather'], + }); + + assert.ok( + result.text.includes('NYC') || + result.text.includes('sunny') || + result.text.includes('72') + ); + }); + + it('should handle multi-turn conversations', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + // First turn + const response1 = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'My name is Alice', + }); + + // Second turn with conversation history + const response2 = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: "What's my name?", + messages: response1.messages, + }); + + // Verify conversation history is maintained + assert.ok( + response2.messages.length >= 2, + 'Should have conversation history' + ); + assert.strictEqual(response2.messages[0].role, 'user'); + assert.ok( + response2.messages[0].content[0].text?.includes('Alice') || + response2.messages[0].content[0].text?.includes('name') + ); + }); + + it('should stream responses with streaming callback', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [ + mockContentBlockStart('Hello'), + mockTextChunk(' world'), + mockTextChunk('!'), + ], + messageResponse: { + content: [{ type: 'text', text: 'Hello world!', citations: null }], + usage: { + input_tokens: 5, + output_tokens: 15, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }); + + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + const chunks: any[] = []; + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'Say hello world', + streamingCallback: (chunk) => { + chunks.push(chunk); + }, + }); + + assert.ok(chunks.length > 0, 'Should have received streaming chunks'); + assert.ok(result.text, 'Should have final response text'); + }); + + it('should handle media/image inputs', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); - it.todo('should handle multi-turn conversations'); + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + messages: [ + { + role: 'user', + content: [ + { text: 'Describe this image:' }, + { + media: { + url: 'data:image/png;base64,R0lGODlhAQABAAAAACw=', + contentType: 'image/png', + }, + }, + ], + }, + ], + }); - it.todo('should stream responses with streaming callback'); + assert.ok(result.text, 'Should generate response for image input'); + }); - it.todo('should handle media/image inputs'); + it('should propagate API errors correctly', async () => { + const apiError = new Error('API Error: 401 Unauthorized'); + const mockClient = createMockAnthropicClient({ + shouldError: apiError, + }); - it.todo('should propagate API errors correctly'); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); - it.todo('should respect abort signals for cancellation'); + await assert.rejects( + async () => { + await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'Hello', + }); + }, + (error: Error) => { + assert.strictEqual(error.message, 'API Error: 401 Unauthorized'); + return true; + } + ); + }); - it.todo('should track token usage in responses'); + it('should respect abort signals for cancellation', async () => { + // Note: Detailed abort signal handling is tested in converters_test.ts + // This test verifies that errors (including abort errors) are properly propagated at the integration layer + const mockClient = createMockAnthropicClient({ + shouldError: new Error('AbortError'), + }); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + await assert.rejects( + async () => { + await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'Hello', + }); + }, + (error: Error) => { + // Should propagate the error + assert.ok( + error.message.includes('AbortError'), + 'Should propagate errors' + ); + return true; + } + ); + }); + + it('should track token usage in responses', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: { + usage: { + input_tokens: 25, + output_tokens: 50, + cache_creation_input_tokens: 5, + cache_read_input_tokens: 10, + }, + }, + }); + + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'Hello', + }); + + assert.ok(result.usage, 'Should have usage information'); + assert.strictEqual(result.usage.inputTokens, 25); + assert.strictEqual(result.usage.outputTokens, 50); + }); }); diff --git a/js/plugins/anthropic/tests/mocks/anthropic-client.ts b/js/plugins/anthropic/tests/mocks/anthropic-client.ts index f6e2b743ed..86fb6091c8 100644 --- a/js/plugins/anthropic/tests/mocks/anthropic-client.ts +++ b/js/plugins/anthropic/tests/mocks/anthropic-client.ts @@ -23,9 +23,13 @@ import { mock } from 'node:test'; export interface MockAnthropicClientOptions { messageResponse?: Partial; + sequentialResponses?: Partial[]; // For tool calling - multiple responses streamChunks?: MessageStreamEvent[]; modelList?: Array<{ id: string; display_name?: string }>; shouldError?: Error; + streamErrorAfterChunk?: number; // Throw error after this many chunks + streamError?: Error; // Error to throw during streaming + abortSignal?: AbortSignal; // Abort signal to check } /** @@ -39,19 +43,41 @@ export function createMockAnthropicClient( ...options.messageResponse, }; + // Support sequential responses for tool calling workflows + let callCount = 0; const createStub = options.shouldError ? mock.fn(async () => { throw options.shouldError; }) - : mock.fn(async () => messageResponse); + : options.sequentialResponses + ? mock.fn(async () => { + const response = + options.sequentialResponses![callCount] || messageResponse; + callCount++; + return { + ...mockDefaultMessage(), + ...response, + }; + }) + : mock.fn(async () => messageResponse); const streamStub = options.shouldError ? mock.fn(() => { throw options.shouldError; }) - : mock.fn(() => - createMockStream(options.streamChunks || [], messageResponse as Message) - ); + : mock.fn((body: any, opts?: { signal?: AbortSignal }) => { + // Check abort signal before starting stream + if (opts?.signal?.aborted) { + throw new Error('AbortError'); + } + return createMockStream( + options.streamChunks || [], + messageResponse as Message, + options.streamErrorAfterChunk, + options.streamError, + opts?.signal + ); + }); const listStub = options.shouldError ? mock.fn(async () => { @@ -75,12 +101,34 @@ export function createMockAnthropicClient( /** * Creates a mock async iterable stream for streaming responses */ -function createMockStream(chunks: MessageStreamEvent[], finalMsg: Message) { +function createMockStream( + chunks: MessageStreamEvent[], + finalMsg: Message, + errorAfterChunk?: number, + streamError?: Error, + abortSignal?: AbortSignal +) { let index = 0; return { [Symbol.asyncIterator]() { return { async next() { + // Check abort signal + if (abortSignal?.aborted) { + const error = new Error('AbortError'); + error.name = 'AbortError'; + throw error; + } + + // Check if we should throw an error after this chunk + if ( + errorAfterChunk !== undefined && + streamError && + index >= errorAfterChunk + ) { + throw streamError; + } + if (index < chunks.length) { return { value: chunks[index++], done: false }; } @@ -89,6 +137,12 @@ function createMockStream(chunks: MessageStreamEvent[], finalMsg: Message) { }; }, async finalMessage() { + // Check abort signal before returning final message + if (abortSignal?.aborted) { + const error = new Error('AbortError'); + error.name = 'AbortError'; + throw error; + } return finalMsg; }, }; From fbb2256609bc38ea4834359e37bb112172cb1dd6 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 4 Nov 2025 16:35:37 +0000 Subject: [PATCH 12/51] refactor: some small code improvements --- js/plugins/anthropic/src/claude.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index 967973fb4b..959e086ddb 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -272,7 +272,7 @@ export function toAnthropicToolResponseContent( part: Part ): TextBlockParam | ImageBlockParam { if (!part.toolResponse) { - throw Error( + throw new Error( `Invalid genkit part provided to toAnthropicToolResponseContent: ${JSON.stringify( part )}.` @@ -393,7 +393,7 @@ export function toAnthropicMessageContent( const { data, contentType } = extractDataFromBase64Url(part.media.url) ?? {}; if (!data) { - throw Error( + throw new Error( `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( part.media )}.` @@ -404,7 +404,7 @@ export function toAnthropicMessageContent( const resolvedMediaType: string | undefined = part.media.contentType ?? contentType; if (!resolvedMediaType) { - throw new Error(`Invalid media type: ${resolvedMediaType}`); + throw new Error('Media type is required but was not provided'); } if (!Object.values(MediaType).includes(resolvedMediaType as MediaType)) { throw new Error(`Unsupported media type: ${resolvedMediaType}`); @@ -449,7 +449,7 @@ export function toAnthropicMessageContent( content: [toAnthropicToolResponseContent(part)], }; } - throw Error( + throw new Error( `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( part )}.` @@ -728,7 +728,7 @@ const GENERIC_CLAUDE_MODEL_INFO = { /** * Defines a Claude model with the given name and Anthropic client. - * accepts any model name and lets the APIvalidate it. If the model is in SUPPORTED_CLAUDE_MODELS, uses that modelRef + * Accepts any model name and lets the API validate it. If the model is in SUPPORTED_CLAUDE_MODELS, uses that modelRef * for better defaults; otherwise creates a generic model reference. */ export function claudeModel( From b87e6b7acddd65ea38f762d030fe90b19833da8b Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 27 Oct 2025 14:27:45 +0000 Subject: [PATCH 13/51] feat(js/plugins/anthropic): add .model() shim for convenient model references --- js/plugins/anthropic/src/claude.ts | 35 +++++++++++++++++++++++++++++- js/plugins/anthropic/src/index.ts | 30 +++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts index 959e086ddb..4d51fe20c0 100644 --- a/js/plugins/anthropic/src/claude.ts +++ b/js/plugins/anthropic/src/claude.ts @@ -220,6 +220,11 @@ export const SUPPORTED_CLAUDE_MODELS: Record< 'claude-4-1-opus': claude41Opus, }; +export type KnownClaudeModels = keyof typeof SUPPORTED_CLAUDE_MODELS; +export type ClaudeModelName = string; +export type AnthropicConfigSchemaType = typeof AnthropicConfigSchema; +export type ClaudeConfig = z.infer; + /** * Converts a Genkit role to the corresponding Anthropic role. */ @@ -392,7 +397,7 @@ export function toAnthropicMessageContent( // Handle non-PDF media (images) - existing logic const { data, contentType } = extractDataFromBase64Url(part.media.url) ?? {}; - if (!data) { + if (!data || !contentType) { throw new Error( `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( part.media @@ -726,6 +731,34 @@ const GENERIC_CLAUDE_MODEL_INFO = { }, }; +/** + * Creates a model reference for a Claude model. + * This allows referencing models without initializing the plugin. + */ +export function claudeModelReference( + name: string, + config?: z.infer +): ModelReference { + const knownModel = SUPPORTED_CLAUDE_MODELS[name]; + if (knownModel) { + return modelRef({ + name: knownModel.name, + namespace: 'anthropic', + info: knownModel.info, + configSchema: knownModel.configSchema, + config, + }); + } + + // For unknown models, create a basic reference + return modelRef({ + name, + namespace: 'anthropic', + configSchema: AnthropicConfigSchema, + config, + }); +} + /** * Defines a Claude model with the given name and Anthropic client. * Accepts any model name and lets the API validate it. If the model is in SUPPORTED_CLAUDE_MODELS, uses that modelRef diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index d34841d3ac..e5343bb42f 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -21,10 +21,14 @@ import { type GenkitPluginV2, } from 'genkit/plugin'; -import { ActionMetadata } from 'genkit'; +import { ActionMetadata, ModelReference, z } from 'genkit'; import { ModelAction } from 'genkit/model'; import { ActionType } from 'genkit/registry'; import { + AnthropicConfigSchemaType, + ClaudeConfig, + ClaudeModelName, + KnownClaudeModels, SUPPORTED_CLAUDE_MODELS, claude35Haiku, claude3Haiku, @@ -34,6 +38,7 @@ import { claude4Opus, claude4Sonnet, claudeModel, + claudeModelReference, } from './claude.js'; import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; @@ -123,7 +128,7 @@ function getAnthropicClient(options?: PluginOptions): Anthropic { * }); * ``` */ -export const anthropic = (options?: PluginOptions): GenkitPluginV2 => { +function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { const client = getAnthropicClient(options); let listActionsCache: ActionMetadata[] | null = null; @@ -153,6 +158,27 @@ export const anthropic = (options?: PluginOptions): GenkitPluginV2 => { return listActionsCache; }, }); +} + +export type AnthropicPlugin = { + (pluginOptions?: PluginOptions): GenkitPluginV2; + model( + name: KnownClaudeModels | (ClaudeModelName & {}), + config?: ClaudeConfig + ): ModelReference; + model(name: string, config?: any): ModelReference; +}; + +/** + * Anthropic AI plugin for Genkit. + * Includes Claude models (3, 3.5, 3.7, and 4 series). + */ +export const anthropic = anthropicPlugin as AnthropicPlugin; +(anthropic as any).model = ( + name: string, + config?: any +): ModelReference => { + return claudeModelReference(name, config); }; export default anthropic; From ff40bb8c1eea96635d73e86163548b73a788363e Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Fri, 7 Nov 2025 12:00:27 +0000 Subject: [PATCH 14/51] refactor(anthropic): a lot of refactoring and add beta api --- js/plugins/anthropic/package.json | 2 +- js/plugins/anthropic/src/claude.ts | 784 ----------------------------- js/plugins/anthropic/src/index.ts | 2 +- js/plugins/anthropic/src/models.ts | 274 ++++++++++ js/plugins/anthropic/src/runner.ts | 605 ++++++++++++++++++++++ js/plugins/anthropic/src/types.ts | 31 ++ js/pnpm-lock.yaml | 73 ++- 7 files changed, 964 insertions(+), 807 deletions(-) delete mode 100644 js/plugins/anthropic/src/claude.ts create mode 100644 js/plugins/anthropic/src/models.ts create mode 100644 js/plugins/anthropic/src/runner.ts diff --git a/js/plugins/anthropic/package.json b/js/plugins/anthropic/package.json index 12040d0919..fba53dafea 100644 --- a/js/plugins/anthropic/package.json +++ b/js/plugins/anthropic/package.json @@ -29,7 +29,7 @@ "genkit": "workspace:^" }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" + "@anthropic-ai/sdk": "^0.68.0" }, "devDependencies": { "@types/node": "^20.11.16", diff --git a/js/plugins/anthropic/src/claude.ts b/js/plugins/anthropic/src/claude.ts deleted file mode 100644 index 4d51fe20c0..0000000000 --- a/js/plugins/anthropic/src/claude.ts +++ /dev/null @@ -1,784 +0,0 @@ -/** - * 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 type Anthropic from '@anthropic-ai/sdk'; -import type { - ContentBlock, - DocumentBlockParam, - ImageBlockParam, - Message, - MessageCreateParams, - MessageParam, - MessageStreamEvent, - TextBlock, - TextBlockParam, - Tool, - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages'; -import type { - GenerateRequest, - GenerateResponseData, - MessageData, - ModelReference, - ModelResponseData, - Part, - Role, - StreamingCallback, -} from 'genkit'; -import { - GenerationCommonConfigSchema, - Message as GenkitMessage, - z, -} from 'genkit'; -import type { - GenerateResponseChunkData, - ModelAction, - ToolDefinition, -} from 'genkit/model'; -import { modelRef } from 'genkit/model'; -import { model } from 'genkit/plugin'; - -import { MediaType, type Media } from './types.js'; - -/** - * Default maximum output tokens for Claude models when not specified in the request. - */ -const DEFAULT_MAX_OUTPUT_TOKENS = 4096; - -export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ - tool_choice: z - .union([ - z.object({ - type: z.literal('auto'), - }), - z.object({ - type: z.literal('any'), - }), - z.object({ - type: z.literal('tool'), - name: z.string(), - }), - ]) - .optional(), - metadata: z - .object({ - user_id: z.string().optional(), - }) - .optional(), -}); - -export const claude4Sonnet = modelRef({ - name: 'claude-4-sonnet', - namespace: 'anthropic', - info: { - versions: ['claude-sonnet-4-20250514'], - label: 'Anthropic - Claude 4 Sonnet', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-sonnet-4-20250514', -}); - -export const claude3Haiku = modelRef({ - name: 'claude-3-haiku', - namespace: 'anthropic', - info: { - versions: ['claude-3-haiku-20240307'], - label: 'Anthropic - Claude 3 Haiku', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-3-haiku-20240307', -}); - -export const claude4Opus = modelRef({ - name: 'claude-4-opus', - namespace: 'anthropic', - info: { - versions: ['claude-opus-4-20250514'], - label: 'Anthropic - Claude 4 Opus', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-opus-4-20250514', -}); - -export const claude35Haiku = modelRef({ - name: 'claude-3-5-haiku', - namespace: 'anthropic', - info: { - versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku-latest'], - label: 'Anthropic - Claude 3.5 Haiku', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-3-5-haiku-latest', -}); - -export const claude45Sonnet = modelRef({ - name: 'claude-4-5-sonnet', - namespace: 'anthropic', - info: { - versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-latest'], - label: 'Anthropic - Claude 4.5 Sonnet', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-sonnet-4-5-latest', -}); - -export const claude45Haiku = modelRef({ - name: 'claude-4-5-haiku', - namespace: 'anthropic', - info: { - versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5-latest'], - label: 'Anthropic - Claude 4.5 Haiku', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-haiku-4-5-latest', -}); - -export const claude41Opus = modelRef({ - name: 'claude-4-1-opus', - namespace: 'anthropic', - info: { - versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1-latest'], - label: 'Anthropic - Claude 4.1 Opus', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicConfigSchema, - version: 'claude-opus-4-1-latest', -}); - -export const SUPPORTED_CLAUDE_MODELS: Record< - string, - ModelReference -> = { - 'claude-3-haiku': claude3Haiku, - 'claude-3-5-haiku': claude35Haiku, - 'claude-4-sonnet': claude4Sonnet, - 'claude-4-opus': claude4Opus, - 'claude-4-5-sonnet': claude45Sonnet, - 'claude-4-5-haiku': claude45Haiku, - 'claude-4-1-opus': claude41Opus, -}; - -export type KnownClaudeModels = keyof typeof SUPPORTED_CLAUDE_MODELS; -export type ClaudeModelName = string; -export type AnthropicConfigSchemaType = typeof AnthropicConfigSchema; -export type ClaudeConfig = z.infer; - -/** - * Converts a Genkit role to the corresponding Anthropic role. - */ -export function toAnthropicRole( - role: Role, - toolMessageType?: 'tool_use' | 'tool_result' -): MessageParam['role'] { - switch (role) { - case 'user': - return 'user'; - case 'model': - return 'assistant'; - case 'tool': - return toolMessageType === 'tool_use' ? 'assistant' : 'user'; - default: - throw new Error(`role ${role} doesn't map to an Anthropic role.`); - } -} - -const isMediaObject = (obj: unknown): obj is Media => - typeof obj === 'object' && - obj !== null && - 'url' in obj && - typeof (obj as Media).url === 'string'; - -/** - * Checks if a URL is a data URL (starts with 'data:'). - * This follows the Google GenAI plugin pattern for distinguishing inline data from file references. - */ -const isDataUrl = (url: string): boolean => { - return url.startsWith('data:'); -}; - -const extractDataFromBase64Url = ( - url: string -): { data: string; contentType: string } | null => { - const match = url.match(/^data:([^;]+);base64,(.+)$/); - return ( - match && { - contentType: match[1], - data: match[2], - } - ); -}; - -/** - * Converts a Genkit message Part to the corresponding Anthropic TextBlockParam or ImageBlockParam. - */ -export function toAnthropicToolResponseContent( - part: Part -): TextBlockParam | ImageBlockParam { - if (!part.toolResponse) { - throw new Error( - `Invalid genkit part provided to toAnthropicToolResponseContent: ${JSON.stringify( - part - )}.` - ); - } - - // Check if the output is a media object or a string - const isMedia = isMediaObject(part.toolResponse?.output); - const isString = typeof part.toolResponse?.output === 'string'; - let base64Data; - if (isMedia) { - base64Data = extractDataFromBase64Url( - (part.toolResponse?.output as Media).url - ); - } else if (isString) { - base64Data = extractDataFromBase64Url(part.toolResponse?.output as string); - } - - // Handle media content - if (base64Data) { - const resolvedMediaType: string | undefined = - (part.toolResponse?.output as Media)?.contentType ?? - base64Data?.contentType; - if ( - !resolvedMediaType || - !Object.values(MediaType).includes(resolvedMediaType as MediaType) - ) { - throw new Error(`Invalid media type: ${resolvedMediaType}`); - } - const mediaTypeValue: MediaType = resolvedMediaType as MediaType; - - return { - type: 'image', - source: { - type: 'base64', - data: base64Data.data, - media_type: mediaTypeValue, - }, - }; - } - - // Handle text content - return { - type: 'text', - text: isString - ? (part.toolResponse?.output as string) - : JSON.stringify(part.toolResponse?.output), - }; -} - -/** - * Converts a Genkit Part to the corresponding Anthropic TextBlock, ImageBlockParam, etc. - */ -export function toAnthropicMessageContent( - part: Part -): - | TextBlock - | ImageBlockParam - | DocumentBlockParam - | ToolUseBlockParam - | ToolResultBlockParam { - if (part.text) { - // Anthropic SDK expects citations field to be explicitly set to null - // when not provided (tests confirm this pattern is correct) - return { - type: 'text', - text: part.text, - citations: null, - }; - } - if (part.media) { - const resolvedContentType = part.media.contentType; - - // Check if this is a PDF document - if (resolvedContentType === 'application/pdf') { - const url = part.media.url; - - if (isDataUrl(url)) { - // Extract base64 data and MIME type from data URL - const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); - if (!base64Match) { - throw new Error( - `Invalid PDF data URL format: ${url.substring(0, 50)}...` - ); - } - - const extractedContentType = base64Match[1]; - const base64Data = base64Match[2]; - - // Verify the extracted type matches PDF - if (extractedContentType !== 'application/pdf') { - throw new Error( - `PDF contentType mismatch: expected application/pdf, got ${extractedContentType}` - ); - } - - return { - type: 'document', - source: { - type: 'base64', - media_type: 'application/pdf', - data: base64Data, - }, - }; - } else { - // File URL (HTTP/HTTPS/other) - contentType is already verified as 'application/pdf' - return { - type: 'document', - source: { - type: 'url', - url: url, - }, - }; - } - } - - // Handle non-PDF media (images) - existing logic - const { data, contentType } = - extractDataFromBase64Url(part.media.url) ?? {}; - if (!data || !contentType) { - throw new Error( - `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( - part.media - )}.` - ); - } - - // Resolve and validate the media type - const resolvedMediaType: string | undefined = - part.media.contentType ?? contentType; - if (!resolvedMediaType) { - throw new Error('Media type is required but was not provided'); - } - if (!Object.values(MediaType).includes(resolvedMediaType as MediaType)) { - throw new Error(`Unsupported media type: ${resolvedMediaType}`); - } - const mediaTypeValue: MediaType = resolvedMediaType as MediaType; - - return { - type: 'image', - source: { - type: 'base64', - data, - media_type: mediaTypeValue, - }, - }; - } - if (part.toolRequest) { - if (!part.toolRequest.ref) { - throw new Error( - `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( - part.toolRequest - )}` - ); - } - return { - type: 'tool_use', - id: part.toolRequest.ref, - name: part.toolRequest.name, - input: part.toolRequest.input, - }; - } - if (part.toolResponse) { - if (!part.toolResponse.ref) { - throw new Error( - `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( - part.toolResponse - )}` - ); - } - return { - type: 'tool_result', - tool_use_id: part.toolResponse.ref, - content: [toAnthropicToolResponseContent(part)], - }; - } - throw new Error( - `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( - part - )}.` - ); -} - -/** - * Converts a Genkit MessageData array to Anthropic system message and MessageParam array. - * @param messages The Genkit MessageData array to convert. - * @returns An object containing the optional Anthropic system message and the array of Anthropic MessageParam objects. - */ -export function toAnthropicMessages(messages: MessageData[]): { - system?: string; - messages: MessageParam[]; -} { - const system = - messages[0]?.role === 'system' ? messages[0].content?.[0]?.text : undefined; - const messagesToIterate = system ? messages.slice(1) : messages; - const anthropicMsgs: MessageParam[] = []; - for (const message of messagesToIterate) { - const msg = new GenkitMessage(message); - const content = msg.content.map(toAnthropicMessageContent); - const toolMessageType = content.find( - (c) => c.type === 'tool_use' || c.type === 'tool_result' - ) as ToolUseBlockParam | ToolResultBlockParam; - const role = toAnthropicRole(message.role, toolMessageType?.type); - anthropicMsgs.push({ - role: role, - content, - }); - } - return { system, messages: anthropicMsgs }; -} - -/** - * Converts a Genkit ToolDefinition to an Anthropic Tool object. - * @param tool The Genkit ToolDefinition to convert. - * @returns The converted Anthropic Tool object. - */ -export function toAnthropicTool(tool: ToolDefinition): Tool { - return { - name: tool.name, - description: tool.description, - input_schema: tool.inputSchema as Tool.InputSchema, - }; -} - -/** - * Converts an Anthropic content block to a Genkit Part object. - * @param contentBlock The Anthropic content block to convert. - * @returns The converted Genkit Part object. - * @param event The Anthropic message stream event to convert. - * @returns The converted Genkit Part object if the event is a content block - * start or delta, otherwise undefined. - */ -function fromAnthropicContentBlock(contentBlock: ContentBlock): Part { - if (contentBlock.type === 'tool_use') { - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }; - } else if (contentBlock.type === 'text') { - return { text: contentBlock.text }; - } else if (contentBlock.type === 'thinking') { - return { text: contentBlock.thinking }; - } else if (contentBlock.type === 'redacted_thinking') { - return { text: contentBlock.data }; - } else { - // Handle unexpected content block types - // Log warning for debugging, but return empty text to avoid breaking the flow - const unknownType = (contentBlock as { type: string }).type; - console.warn( - `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` - ); - return { text: '' }; - } -} - -/** - * Converts an Anthropic message stream event to a Genkit Part object. - */ -export function fromAnthropicContentBlockChunk( - event: MessageStreamEvent -): Part | undefined { - if ( - event.type !== 'content_block_start' && - event.type !== 'content_block_delta' - ) { - return; - } - const eventField = - event.type === 'content_block_start' ? 'content_block' : 'delta'; - return ['text', 'text_delta'].includes(event[eventField].type) - ? { - text: event[eventField].text, - } - : { - toolRequest: { - ref: event[eventField].id, - name: event[eventField].name, - input: event[eventField].input, - }, - }; -} - -export function fromAnthropicStopReason( - reason: Message['stop_reason'] -): ModelResponseData['finishReason'] { - switch (reason) { - case 'max_tokens': - return 'length'; - case 'end_turn': - // fall through - case 'stop_sequence': - // fall through - case 'tool_use': - return 'stop'; - case null: - return 'unknown'; - default: - return 'other'; - } -} - -export function fromAnthropicResponse(response: Message): GenerateResponseData { - return { - candidates: [ - { - index: 0, - finishReason: fromAnthropicStopReason(response.stop_reason), - message: { - role: 'model', - content: response.content.map(fromAnthropicContentBlock), - }, - }, - ], - usage: { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - }, - custom: response, - }; -} - -/** - * Converts an Anthropic request to an Anthropic API request body. - * @param modelName The name of the Anthropic model to use. - * @param request The Genkit GenerateRequest to convert. - * @param stream Whether to stream the response. - * @param cacheSystemPrompt Whether to cache the system prompt. - * @returns The converted Anthropic API request body. - * @throws An error if an unsupported output format is requested. - */ -export function toAnthropicRequestBody( - modelName: string, - request: GenerateRequest, - stream?: boolean, - cacheSystemPrompt?: boolean -): MessageCreateParams { - // Use supported model ref if available for version mapping, otherwise use modelName directly - const model = SUPPORTED_CLAUDE_MODELS[modelName]; - const { system, messages } = toAnthropicMessages(request.messages); - const mappedModelName = - request.config?.version ?? model?.version ?? modelName; - const body: MessageCreateParams = { - system: - cacheSystemPrompt && system - ? [ - { - type: 'text', - text: system, - cache_control: { type: 'ephemeral' }, - }, - ] - : system, - messages, - tools: request.tools?.map(toAnthropicTool), - max_tokens: request.config?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, - model: mappedModelName, - top_k: request.config?.topK, - top_p: request.config?.topP, - temperature: request.config?.temperature, - stop_sequences: request.config?.stopSequences, - metadata: request.config?.metadata, - tool_choice: request.config?.tool_choice, - stream, - }; - - if (request.output?.format && request.output.format !== 'text') { - throw new Error( - `Only text output format is supported for Claude models currently` - ); - } - // Remove undefined, null, and empty array values using a type-safe approach - const cleanedBody = Object.fromEntries( - Object.entries(body).filter(([_, value]) => { - if (value === undefined || value === null) return false; - if (Array.isArray(value) && value.length === 0) return false; - return true; - }) - ) as MessageCreateParams; - return cleanedBody; -} - -/** - * Creates the runner used by Genkit to interact with the Claude model. - * @param name The name of the Claude model. - * @param client The Anthropic client instance. - * @param cacheSystemPrompt Whether to cache the system prompt. - * @returns The runner that Genkit will call when the model is invoked. - */ -export function claudeRunner( - name: string, - client: Anthropic, - cacheSystemPrompt?: boolean -) { - return async ( - request: GenerateRequest, - { - streamingRequested, - sendChunk, - abortSignal, - }: { - streamingRequested: boolean; - sendChunk: StreamingCallback; - abortSignal: AbortSignal; - } - ): Promise => { - let response: Message; - const body = toAnthropicRequestBody( - name, - request, - streamingRequested, - cacheSystemPrompt - ); - - if (streamingRequested) { - const stream = client.messages.stream(body, { signal: abortSignal }); - for await (const chunk of stream) { - const c = fromAnthropicContentBlockChunk(chunk); - if (c) { - sendChunk({ - index: 0, - content: [c], - }); - } - } - response = await stream.finalMessage(); - } else { - response = (await client.messages.create(body, { - signal: abortSignal, - })) as Message; - } - return fromAnthropicResponse(response); - }; -} - -/** - * Generic Claude model info for unknown/unsupported models. - * Used when a model name is not in SUPPORTED_CLAUDE_MODELS. - */ -const GENERIC_CLAUDE_MODEL_INFO = { - versions: [], - label: 'Anthropic - Claude', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, -}; - -/** - * Creates a model reference for a Claude model. - * This allows referencing models without initializing the plugin. - */ -export function claudeModelReference( - name: string, - config?: z.infer -): ModelReference { - const knownModel = SUPPORTED_CLAUDE_MODELS[name]; - if (knownModel) { - return modelRef({ - name: knownModel.name, - namespace: 'anthropic', - info: knownModel.info, - configSchema: knownModel.configSchema, - config, - }); - } - - // For unknown models, create a basic reference - return modelRef({ - name, - namespace: 'anthropic', - configSchema: AnthropicConfigSchema, - config, - }); -} - -/** - * Defines a Claude model with the given name and Anthropic client. - * Accepts any model name and lets the API validate it. If the model is in SUPPORTED_CLAUDE_MODELS, uses that modelRef - * for better defaults; otherwise creates a generic model reference. - */ -export function claudeModel( - name: string, - client: Anthropic, - cacheSystemPrompt?: boolean -): ModelAction { - // Use supported model ref if available, otherwise create generic model ref - const modelRef = SUPPORTED_CLAUDE_MODELS[name]; - const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO; - - return model( - { - name: `anthropic/${name}`, - ...modelInfo, - configSchema: AnthropicConfigSchema, - }, - claudeRunner(name, client, cacheSystemPrompt) - ); -} diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index e5343bb42f..ee672b5e7e 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -39,7 +39,7 @@ import { claude4Sonnet, claudeModel, claudeModelReference, -} from './claude.js'; +} from './models.js'; import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; export { diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts new file mode 100644 index 0000000000..620cdedeb8 --- /dev/null +++ b/js/plugins/anthropic/src/models.ts @@ -0,0 +1,274 @@ +/** + * 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 type Anthropic from '@anthropic-ai/sdk'; +import type { + GenerateRequest, + GenerateResponseData, + ModelReference, + StreamingCallback, +} from 'genkit'; +import { z } from 'genkit'; +import type { GenerateResponseChunkData, ModelAction } from 'genkit/model'; +import { modelRef } from 'genkit/model'; +import { model } from 'genkit/plugin'; + +import { BetaRunner, RegularRunner } from './runner.js'; +import { AnthropicConfigSchema } from './types.js'; + +export const claude4Sonnet = modelRef({ + name: 'claude-4-sonnet', + namespace: 'anthropic', + info: { + versions: ['claude-sonnet-4-20250514'], + label: 'Anthropic - Claude 4 Sonnet', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-sonnet-4-20250514', +}); + +export const claude3Haiku = modelRef({ + name: 'claude-3-haiku', + namespace: 'anthropic', + info: { + versions: ['claude-3-haiku-20240307'], + label: 'Anthropic - Claude 3 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-haiku-20240307', +}); + +export const claude4Opus = modelRef({ + name: 'claude-4-opus', + namespace: 'anthropic', + info: { + versions: ['claude-opus-4-20250514'], + label: 'Anthropic - Claude 4 Opus', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-opus-4-20250514', +}); + +export const claude35Haiku = modelRef({ + name: 'claude-3-5-haiku', + namespace: 'anthropic', + info: { + versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku-latest'], + label: 'Anthropic - Claude 3.5 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-3-5-haiku-latest', +}); + +export const claude45Sonnet = modelRef({ + name: 'claude-4-5-sonnet', + namespace: 'anthropic', + info: { + versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-latest'], + label: 'Anthropic - Claude 4.5 Sonnet', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-sonnet-4-5-latest', +}); + +export const claude45Haiku = modelRef({ + name: 'claude-4-5-haiku', + namespace: 'anthropic', + info: { + versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5-latest'], + label: 'Anthropic - Claude 4.5 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-haiku-4-5-latest', +}); + +export const claude41Opus = modelRef({ + name: 'claude-4-1-opus', + namespace: 'anthropic', + info: { + versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1-latest'], + label: 'Anthropic - Claude 4.1 Opus', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + configSchema: AnthropicConfigSchema, + version: 'claude-opus-4-1-latest', +}); + +export const SUPPORTED_CLAUDE_MODELS: Record< + string, + ModelReference +> = { + 'claude-3-haiku': claude3Haiku, + 'claude-3-5-haiku': claude35Haiku, + 'claude-4-sonnet': claude4Sonnet, + 'claude-4-opus': claude4Opus, + 'claude-4-5-sonnet': claude45Sonnet, + 'claude-4-5-haiku': claude45Haiku, + 'claude-4-1-opus': claude41Opus, +}; + +export type KnownClaudeModels = keyof typeof SUPPORTED_CLAUDE_MODELS; +export type ClaudeModelName = string; +export type AnthropicConfigSchemaType = typeof AnthropicConfigSchema; +export type ClaudeConfig = z.infer; + +/** + * Creates the runner used by Genkit to interact with the Claude model. + * @param name The name of the Claude model. + * @param client The Anthropic client instance. + * @param cacheSystemPrompt Whether to cache the system prompt. + * @returns The runner that Genkit will call when the model is invoked. + */ +export function claudeRunner( + name: string, + client: Anthropic, + cacheSystemPrompt?: boolean +) { + return async ( + request: GenerateRequest, + { + streamingRequested, + sendChunk, + abortSignal, + }: { + streamingRequested: boolean; + sendChunk: StreamingCallback; + abortSignal: AbortSignal; + } + ): Promise => { + const isBeta = request.config?.beta?.enabled ?? false; + const api = isBeta + ? new BetaRunner(name, client, cacheSystemPrompt) + : new RegularRunner(name, client, cacheSystemPrompt); + return api.run(request, { streamingRequested, sendChunk, abortSignal }); + }; +} + +/** + * Generic Claude model info for unknown/unsupported models. + * Used when a model name is not in SUPPORTED_CLAUDE_MODELS. + */ +const GENERIC_CLAUDE_MODEL_INFO = { + versions: [], + label: 'Anthropic - Claude', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, +}; + +/** + * Creates a model reference for a Claude model. + * This allows referencing models without initializing the plugin. + */ +export function claudeModelReference( + name: string, + config?: z.infer +): ModelReference { + const knownModel = SUPPORTED_CLAUDE_MODELS[name]; + if (knownModel) { + return modelRef({ + name: knownModel.name, + namespace: 'anthropic', + info: knownModel.info, + configSchema: knownModel.configSchema, + config, + }); + } + + // For unknown models, create a basic reference + return modelRef({ + name, + namespace: 'anthropic', + configSchema: AnthropicConfigSchema, + config, + }); +} + +/** + * Defines a Claude model with the given name and Anthropic client. + * Accepts any model name and lets the API validate it. If the model is in SUPPORTED_CLAUDE_MODELS, uses that modelRef + * for better defaults; otherwise creates a generic model reference. + */ +export function claudeModel( + name: string, + client: Anthropic, + cacheSystemPrompt?: boolean +): ModelAction { + // Use supported model ref if available, otherwise create generic model ref + const modelRef = SUPPORTED_CLAUDE_MODELS[name]; + const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO; + + return model( + { + name: `anthropic/${name}`, + ...modelInfo, + configSchema: AnthropicConfigSchema, + }, + claudeRunner(name, client, cacheSystemPrompt) + ); +} diff --git a/js/plugins/anthropic/src/runner.ts b/js/plugins/anthropic/src/runner.ts new file mode 100644 index 0000000000..1e6d8cd180 --- /dev/null +++ b/js/plugins/anthropic/src/runner.ts @@ -0,0 +1,605 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Anthropic } from '@anthropic-ai/sdk'; +import type { + ContentBlock, + DocumentBlockParam, + ImageBlockParam, + Message, + MessageCreateParams, + MessageParam, + MessageStreamEvent, + TextBlock, + TextBlockParam, + Tool, + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/messages.mjs'; +import type { + GenerateRequest, + GenerateResponseChunkData, + GenerateResponseData, + MessageData, + ModelResponseData, + Part, + Role, +} from 'genkit'; +import { Message as GenkitMessage } from 'genkit'; +import type { ToolDefinition } from 'genkit/model'; + +import { SUPPORTED_CLAUDE_MODELS } from './models'; +import { AnthropicConfigSchema, Media, MediaType } from './types'; + +export abstract class Runner { + protected name: string; + protected client: Anthropic; + protected cacheSystemPrompt?: boolean; + /** + * Default maximum output tokens for Claude models when not specified in the request. + */ + protected readonly DEFAULT_MAX_OUTPUT_TOKENS = 4096; + + constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { + this.name = name; + this.client = client; + this.cacheSystemPrompt = cacheSystemPrompt; + } + + /** + * Converts a Genkit role to the corresponding Anthropic role. + */ + protected toAnthropicRole( + role: Role, + toolMessageType?: 'tool_use' | 'tool_result' + ): MessageParam['role'] { + switch (role) { + case 'user': + return 'user'; + case 'model': + return 'assistant'; + case 'tool': + return toolMessageType === 'tool_use' ? 'assistant' : 'user'; + default: + throw new Error(`role ${role} doesn't map to an Anthropic role.`); + } + } + + protected isMediaObject(obj: unknown): obj is Media { + return ( + typeof obj === 'object' && + obj !== null && + 'url' in obj && + typeof (obj as Media).url === 'string' + ); + } + + /** + * Checks if a URL is a data URL (starts with 'data:'). + * This follows the Google GenAI plugin pattern for distinguishing inline data from file references. + */ + protected isDataUrl(url: string): boolean { + return url.startsWith('data:'); + } + + protected extractDataFromBase64Url( + url: string + ): { data: string; contentType: string } | null { + const match = url.match(/^data:([^;]+);base64,(.+)$/); + return ( + match && { + contentType: match[1], + data: match[2], + } + ); + } + + /** + * Converts a Genkit message Part to the corresponding Anthropic TextBlockParam or ImageBlockParam. + */ + protected toAnthropicToolResponseContent( + part: Part + ): TextBlockParam | ImageBlockParam { + if (!part.toolResponse) { + throw new Error( + `Invalid genkit part provided to toAnthropicToolResponseContent: ${JSON.stringify( + part + )}.` + ); + } + + // Check if the output is a media object or a string + const isMedia = this.isMediaObject(part.toolResponse?.output); + const isString = typeof part.toolResponse?.output === 'string'; + let base64Data; + if (isMedia) { + base64Data = this.extractDataFromBase64Url( + (part.toolResponse?.output as Media).url + ); + } else if (isString) { + base64Data = this.extractDataFromBase64Url( + part.toolResponse?.output as string + ); + } + + // Handle media content + if (base64Data) { + const resolvedMediaType: string | undefined = + (part.toolResponse?.output as Media)?.contentType ?? + base64Data?.contentType; + if ( + !resolvedMediaType || + !Object.values(MediaType).includes(resolvedMediaType as MediaType) + ) { + throw new Error(`Invalid media type: ${resolvedMediaType}`); + } + const mediaTypeValue: MediaType = resolvedMediaType as MediaType; + + return { + type: 'image', + source: { + type: 'base64', + data: base64Data.data, + media_type: mediaTypeValue, + }, + }; + } + + // Handle text content + return { + type: 'text', + text: isString + ? (part.toolResponse?.output as string) + : JSON.stringify(part.toolResponse?.output), + }; + } + + /** + * Converts a Genkit Part to the corresponding Anthropic TextBlock, ImageBlockParam, etc. + */ + protected toAnthropicMessageContent( + part: Part + ): + | TextBlock + | ImageBlockParam + | DocumentBlockParam + | ToolUseBlockParam + | ToolResultBlockParam { + if (part.text) { + // Anthropic SDK expects citations field to be explicitly set to null + // when not provided (tests confirm this pattern is correct) + return { + type: 'text', + text: part.text, + citations: null, + }; + } + if (part.media) { + const resolvedContentType = part.media.contentType; + + // Check if this is a PDF document + if (resolvedContentType === 'application/pdf') { + const url = part.media.url; + + if (this.isDataUrl(url)) { + // Extract base64 data and MIME type from data URL + const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); + if (!base64Match) { + throw new Error( + `Invalid PDF data URL format: ${url.substring(0, 50)}...` + ); + } + + const extractedContentType = base64Match[1]; + const base64Data = base64Match[2]; + + // Verify the extracted type matches PDF + if (extractedContentType !== 'application/pdf') { + throw new Error( + `PDF contentType mismatch: expected application/pdf, got ${extractedContentType}` + ); + } + + return { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: base64Data, + }, + }; + } else { + // File URL (HTTP/HTTPS/other) - contentType is already verified as 'application/pdf' + return { + type: 'document', + source: { + type: 'url', + url: url, + }, + }; + } + } + + // Handle non-PDF media (images) - existing logic + const { data, contentType } = + this.extractDataFromBase64Url(part.media.url) ?? {}; + if (!data || !contentType) { + throw new Error( + `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( + part.media + )}.` + ); + } + + // Resolve and validate the media type + const resolvedMediaType: string | undefined = + part.media.contentType ?? contentType; + if (!resolvedMediaType) { + throw new Error('Media type is required but was not provided'); + } + if (!Object.values(MediaType).includes(resolvedMediaType as MediaType)) { + throw new Error(`Unsupported media type: ${resolvedMediaType}`); + } + const mediaTypeValue: MediaType = resolvedMediaType as MediaType; + + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: mediaTypeValue, + }, + }; + } + if (part.toolRequest) { + if (!part.toolRequest.ref) { + throw new Error( + `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolRequest + )}` + ); + } + return { + type: 'tool_use', + id: part.toolRequest.ref, + name: part.toolRequest.name, + input: part.toolRequest.input, + }; + } + if (part.toolResponse) { + if (!part.toolResponse.ref) { + throw new Error( + `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolResponse + )}` + ); + } + return { + type: 'tool_result', + tool_use_id: part.toolResponse.ref, + content: [this.toAnthropicToolResponseContent(part)], + }; + } + throw new Error( + `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( + part + )}.` + ); + } + + /** + * Converts a Genkit MessageData array to Anthropic system message and MessageParam array. + * @param messages The Genkit MessageData array to convert. + * @returns An object containing the optional Anthropic system message and the array of Anthropic MessageParam objects. + */ + protected toAnthropicMessages(messages: MessageData[]): { + system?: string; + messages: MessageParam[]; + } { + const system = + messages[0]?.role === 'system' + ? messages[0].content?.[0]?.text + : undefined; + const messagesToIterate = system ? messages.slice(1) : messages; + const anthropicMsgs: MessageParam[] = []; + for (const message of messagesToIterate) { + const msg = new GenkitMessage(message); + const content = msg.content.map(this.toAnthropicMessageContent); + const toolMessageType = content.find( + (c) => c.type === 'tool_use' || c.type === 'tool_result' + ) as ToolUseBlockParam | ToolResultBlockParam; + const role = this.toAnthropicRole(message.role, toolMessageType?.type); + anthropicMsgs.push({ + role: role, + content, + }); + } + return { system, messages: anthropicMsgs }; + } + + /** + * Converts a Genkit ToolDefinition to an Anthropic Tool object. + * @param tool The Genkit ToolDefinition to convert. + * @returns The converted Anthropic Tool object. + */ + protected toAnthropicTool(tool: ToolDefinition): Tool { + return { + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema as Tool.InputSchema, + }; + } + + /** + * Converts an Anthropic content block to a Genkit Part object. + * @param contentBlock The Anthropic content block to convert. + * @returns The converted Genkit Part object. + * @param event The Anthropic message stream event to convert. + * @returns The converted Genkit Part object if the event is a content block + * start or delta, otherwise undefined. + */ + protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { + if (contentBlock.type === 'tool_use') { + return { + toolRequest: { + ref: contentBlock.id, + name: contentBlock.name, + input: contentBlock.input, + }, + }; + } else if (contentBlock.type === 'text') { + return { text: contentBlock.text }; + } else if (contentBlock.type === 'thinking') { + return { text: contentBlock.thinking }; + } else if (contentBlock.type === 'redacted_thinking') { + return { text: contentBlock.data }; + } else { + // Handle unexpected content block types + // Log warning for debugging, but return empty text to avoid breaking the flow + const unknownType = (contentBlock as { type: string }).type; + console.warn( + `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` + ); + return { text: '' }; + } + } + + /** + * Converts an Anthropic message stream event to a Genkit Part object. + */ + protected fromAnthropicContentBlockChunk( + event: MessageStreamEvent + ): Part | undefined { + if ( + event.type !== 'content_block_start' && + event.type !== 'content_block_delta' + ) { + return; + } + const eventField = + event.type === 'content_block_start' ? 'content_block' : 'delta'; + return ['text', 'text_delta'].includes(event[eventField].type) + ? { + text: event[eventField].text, + } + : { + toolRequest: { + ref: event[eventField].id, + name: event[eventField].name, + input: event[eventField].input, + }, + }; + } + + protected fromAnthropicStopReason( + reason: Message['stop_reason'] + ): ModelResponseData['finishReason'] { + switch (reason) { + case 'max_tokens': + return 'length'; + case 'end_turn': + // fall through + case 'stop_sequence': + // fall through + case 'tool_use': + return 'stop'; + case null: + return 'unknown'; + default: + return 'other'; + } + } + + protected fromAnthropicResponse(response: Message): GenerateResponseData { + return { + candidates: [ + { + index: 0, + finishReason: this.fromAnthropicStopReason(response.stop_reason), + message: { + role: 'model', + content: response.content.map(this.fromAnthropicContentBlock), + }, + }, + ], + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + }, + custom: response, + }; + } + + /** + * Converts an Anthropic request to an Anthropic API request body. + * @param modelName The name of the Anthropic model to use. + * @param request The Genkit GenerateRequest to convert. + * @param stream Whether to stream the response. + * @param cacheSystemPrompt Whether to cache the system prompt. + * @returns The converted Anthropic API request body. + * @throws An error if an unsupported output format is requested. + */ + protected toAnthropicRequestBody( + modelName: string, + request: GenerateRequest, + stream?: boolean, + cacheSystemPrompt?: boolean + ): MessageCreateParams { + // Use supported model ref if available for version mapping, otherwise use modelName directly + const model = SUPPORTED_CLAUDE_MODELS[modelName]; + const { system, messages } = this.toAnthropicMessages(request.messages); + const mappedModelName = + request.config?.version ?? model?.version ?? modelName; + const body: MessageCreateParams = { + system: + cacheSystemPrompt && system + ? [ + { + type: 'text', + text: system, + cache_control: { type: 'ephemeral' }, + }, + ] + : system, + messages, + tools: request.tools?.map(this.toAnthropicTool), + max_tokens: + request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, + model: mappedModelName, + top_k: request.config?.topK, + top_p: request.config?.topP, + temperature: request.config?.temperature, + stop_sequences: request.config?.stopSequences, + metadata: request.config?.metadata, + tool_choice: request.config?.tool_choice, + stream, + }; + + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + // Remove undefined, null, and empty array values using a type-safe approach + const cleanedBody = Object.fromEntries( + Object.entries(body).filter(([_, value]) => { + if (value === undefined || value === null) return false; + if (Array.isArray(value) && value.length === 0) return false; + return true; + }) + ) as MessageCreateParams; + return cleanedBody; + } + + public abstract run( + request: GenerateRequest, + options: { + streamingRequested: boolean; + sendChunk: (chunk: GenerateResponseChunkData) => void; + abortSignal: AbortSignal; + } + ): Promise; +} + +export class BetaRunner extends Runner { + constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { + super(name, client, cacheSystemPrompt); + } + + public async run( + request: GenerateRequest, + options: { + streamingRequested: boolean; + sendChunk: (chunk: GenerateResponseChunkData) => void; + abortSignal: AbortSignal; + } + ): Promise { + const { streamingRequested, sendChunk, abortSignal } = options; + + let response: Message; + const body = this.toAnthropicRequestBody( + this.name, + request, + streamingRequested, + this.cacheSystemPrompt + ); + + if (streamingRequested) { + const stream = this.client.beta.messages.stream(body, { + signal: abortSignal, + }); + for await (const chunk of stream) { + const c = this.fromAnthropicContentBlockChunk(chunk); + if (c) { + sendChunk({ + index: 0, + content: [c], + }); + } + } + response = (await stream.finalMessage()) as Message; + } else { + response = (await this.client.beta.messages.create(body, { + signal: abortSignal, + })) as Message; + } + return this.fromAnthropicResponse(response); + } +} + +export class RegularRunner extends Runner { + constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { + super(name, client, cacheSystemPrompt); + } + + public async run( + request: GenerateRequest, + options: { + streamingRequested: boolean; + sendChunk: (chunk: GenerateResponseChunkData) => void; + abortSignal: AbortSignal; + } + ): Promise { + const { streamingRequested, sendChunk, abortSignal } = options; + + let response: Message; + const body = this.toAnthropicRequestBody( + this.name, + request, + streamingRequested, + this.cacheSystemPrompt + ); + + if (streamingRequested) { + const stream = this.client.messages.stream(body, { signal: abortSignal }); + for await (const chunk of stream) { + const c = this.fromAnthropicContentBlockChunk(chunk); + if (c) { + sendChunk({ + index: 0, + content: [c], + }); + } + } + response = (await stream.finalMessage()) as Message; + } else { + response = (await this.client.messages.create(body, { + signal: abortSignal, + })) as Message; + } + return this.fromAnthropicResponse(response); + } +} diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 4d23468685..de86217dfe 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -15,6 +15,8 @@ */ import type Anthropic from '@anthropic-ai/sdk'; +import { z } from 'genkit'; +import { GenerationCommonConfigSchema } from 'genkit/model'; /** * Internal symbol for dependency injection in tests. @@ -39,6 +41,35 @@ export interface InternalPluginOptions extends PluginOptions { [__testClient]?: Anthropic; } +export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ + tool_choice: z + .union([ + z.object({ + type: z.literal('auto'), + }), + z.object({ + type: z.literal('any'), + }), + z.object({ + type: z.literal('tool'), + name: z.string(), + }), + ]) + .optional(), + metadata: z + .object({ + user_id: z.string().optional(), + }) + .optional(), + beta: z + .object({ + enabled: z.boolean().default(false), + filesApi: z.boolean().default(false), + webSearch: z.boolean().default(false), + }) + .optional(), +}); + /** * Media object representation with URL and optional content type. */ diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 48172f8ffe..e80faab0ce 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -153,6 +153,10 @@ importers: zod-to-json-schema: specifier: ^3.22.4 version: 3.24.5(zod@3.25.67) + optionalDependencies: + '@genkit-ai/firebase': + specifier: ^1.16.1 + version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) devDependencies: '@types/express': specifier: ^4.17.21 @@ -178,10 +182,13 @@ importers: typescript: specifier: ^4.9.0 version: 4.9.5 +<<<<<<< HEAD optionalDependencies: '@genkit-ai/firebase': specifier: ^1.16.1 version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) +======= +>>>>>>> 6f4fc016b (refactor(anthropic): a lot of refactoring and add beta api) doc-snippets: dependencies: @@ -260,8 +267,8 @@ importers: plugins/anthropic: dependencies: '@anthropic-ai/sdk': - specifier: ^0.39.0 - version: 0.39.0(encoding@0.1.13) + specifier: ^0.68.0 + version: 0.68.0(zod@3.25.67) devDependencies: '@types/node': specifier: ^20.11.16 @@ -976,7 +983,18 @@ importers: version: 3.3.2 openai: specifier: ^4.52.7 +<<<<<<< HEAD version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67) +======= + version: 4.104.0(encoding@0.1.13)(zod@3.25.67) + optionalDependencies: + '@google-cloud/bigquery': + specifier: ^7.8.0 + version: 7.9.4(encoding@0.1.13) + firebase-admin: + specifier: '>=12.2' + version: 13.4.0(encoding@0.1.13) +>>>>>>> 5cbe620e1 (refactor(anthropic): a lot of refactoring and add beta api) devDependencies: '@types/node': specifier: ^20.11.16 @@ -1008,13 +1026,6 @@ importers: typescript: specifier: ^4.9.0 version: 4.9.5 - optionalDependencies: - '@google-cloud/bigquery': - specifier: ^7.8.0 - version: 7.9.4(encoding@0.1.13) - firebase-admin: - specifier: '>=12.2' - version: 13.4.0(encoding@0.1.13) testapps/basic-gemini: dependencies: @@ -2064,8 +2075,14 @@ packages: '@anthropic-ai/sdk@0.24.3': resolution: {integrity: sha512-916wJXO6T6k8R6BAAcLhLPv/pnLGy7YSEBZXZ1XTFbLcTZE8oTy3oDW9WJf9KKZwMvVcePIfoTSvzXHRcGxkQQ==} - '@anthropic-ai/sdk@0.39.0': - resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==} + '@anthropic-ai/sdk@0.68.0': + resolution: {integrity: sha512-SMYAmbbiprG8k1EjEPMTwaTqssDT7Ae+jxcR5kWXiqTlbwMR2AthXtscEVWOHkRfyAV5+y3PFYTJRNa3OJWIEw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true '@anthropic-ai/sdk@0.9.1': resolution: {integrity: sha512-wa1meQ2WSfoY8Uor3EdrJq0jTiZJoKoSii2ZVWRY1oN4Tlr5s59pADg9T79FTbPe1/se5c3pBeZgJL63wmuoBA==} @@ -2227,6 +2244,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.7': resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} engines: {node: '>=6.9.0'} @@ -5930,6 +5951,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -7409,6 +7434,9 @@ packages: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -7846,17 +7874,11 @@ snapshots: transitivePeerDependencies: - encoding - '@anthropic-ai/sdk@0.39.0(encoding@0.1.13)': + '@anthropic-ai/sdk@0.68.0(zod@3.25.67)': dependencies: - '@types/node': 18.19.112 - '@types/node-fetch': 2.6.12 - abort-controller: 3.0.0 - agentkeepalive: 4.5.0 - form-data-encoder: 1.7.2 - formdata-node: 4.4.1 - node-fetch: 2.7.0(encoding@0.1.13) - transitivePeerDependencies: - - encoding + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.67 '@anthropic-ai/sdk@0.9.1(encoding@0.1.13)': dependencies: @@ -8055,6 +8077,8 @@ snapshots: '@babel/core': 7.25.7 '@babel/helper-plugin-utils': 7.25.7 + '@babel/runtime@7.28.4': {} + '@babel/template@7.25.7': dependencies: '@babel/code-frame': 7.25.7 @@ -12741,6 +12765,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.4 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -14256,6 +14285,8 @@ snapshots: triple-beam@1.4.1: {} + ts-algebra@2.0.0: {} + ts-interface-checker@0.1.13: {} ts-jest@29.4.0(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(ts-node@10.9.2(@types/node@20.19.1)(typescript@4.9.5)))(typescript@4.9.5): From 0ecd9c84a7a4f18a9e00e36810b6c0ba8eabd82e Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 10 Nov 2025 12:42:37 +0000 Subject: [PATCH 15/51] refactor(anthropic): pass in message generics, keep two runners separate --- js/plugins/anthropic/src/runner.ts | 1009 ++++++++++++----- js/plugins/anthropic/tests/converters_test.ts | 104 +- js/plugins/anthropic/tests/index_test.ts | 2 +- .../anthropic/tests/mocks/anthropic-client.ts | 83 +- 4 files changed, 903 insertions(+), 295 deletions(-) diff --git a/js/plugins/anthropic/src/runner.ts b/js/plugins/anthropic/src/runner.ts index 1e6d8cd180..977c0eb9a8 100644 --- a/js/plugins/anthropic/src/runner.ts +++ b/js/plugins/anthropic/src/runner.ts @@ -15,6 +15,25 @@ */ import { Anthropic } from '@anthropic-ai/sdk'; +import type { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream'; +import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream'; +import type { + BetaContentBlock, + BetaContentBlockParam, + BetaImageBlockParam, + BetaMCPToolUseBlock, + BetaMessage, + MessageCreateParams as BetaMessageCreateParams, + BetaMessageParam, + BetaRawMessageStreamEvent, + BetaRequestDocumentBlock, + BetaSearchResultBlockParam, + BetaServerToolUseBlock, + BetaStopReason, + BetaTextBlockParam, + BetaToolResultBlockParam, + BetaToolUseBlock, +} from '@anthropic-ai/sdk/resources/beta/messages'; import type { ContentBlock, DocumentBlockParam, @@ -23,12 +42,11 @@ import type { MessageCreateParams, MessageParam, MessageStreamEvent, - TextBlock, TextBlockParam, Tool, ToolResultBlockParam, ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages.mjs'; +} from '@anthropic-ai/sdk/resources/messages'; import type { GenerateRequest, GenerateResponseChunkData, @@ -44,7 +62,30 @@ import type { ToolDefinition } from 'genkit/model'; import { SUPPORTED_CLAUDE_MODELS } from './models'; import { AnthropicConfigSchema, Media, MediaType } from './types'; -export abstract class Runner { +type MessageStreamLike = AsyncIterable & { + finalMessage(): Promise; +}; + +/** + * Type guard to check if a value is a valid MediaType. + */ +function isMediaType(value: string): value is MediaType { + return Object.values(MediaType).includes(value as MediaType); +} + +/** + * Type guard to check if an object is a Media object. + */ +function isMedia(obj: unknown): obj is Media { + return ( + typeof obj === 'object' && + obj !== null && + 'url' in obj && + typeof (obj as Media).url === 'string' + ); +} + +export abstract class Runner { protected name: string; protected client: Anthropic; protected cacheSystemPrompt?: boolean; @@ -62,29 +103,13 @@ export abstract class Runner { /** * Converts a Genkit role to the corresponding Anthropic role. */ - protected toAnthropicRole( + protected abstract toAnthropicRole( role: Role, toolMessageType?: 'tool_use' | 'tool_result' - ): MessageParam['role'] { - switch (role) { - case 'user': - return 'user'; - case 'model': - return 'assistant'; - case 'tool': - return toolMessageType === 'tool_use' ? 'assistant' : 'user'; - default: - throw new Error(`role ${role} doesn't map to an Anthropic role.`); - } - } + ): 'user' | 'assistant'; protected isMediaObject(obj: unknown): obj is Media { - return ( - typeof obj === 'object' && - obj !== null && - 'url' in obj && - typeof (obj as Media).url === 'string' - ); + return isMedia(obj); } /** @@ -108,79 +133,287 @@ export abstract class Runner { } /** - * Converts a Genkit message Part to the corresponding Anthropic TextBlockParam or ImageBlockParam. + * Converts a Genkit message Part to the corresponding Anthropic tool response content. + * Each runner implements this to return its specific API type. */ - protected toAnthropicToolResponseContent( - part: Part - ): TextBlockParam | ImageBlockParam { - if (!part.toolResponse) { - throw new Error( - `Invalid genkit part provided to toAnthropicToolResponseContent: ${JSON.stringify( - part - )}.` + protected abstract toAnthropicToolResponseContent(part: Part): any; + + /** + * Converts a Genkit Part to the corresponding Anthropic content block. + * Each runner implements this to return its specific API type. + */ + protected abstract toAnthropicMessageContent(part: Part): any; + + /** + * Converts a Genkit MessageData array to Anthropic system and messages. + * Each runner implements this to return its specific API types. + */ + protected abstract toAnthropicMessages(messages: MessageData[]): { + system?: string; + messages: any[]; + }; + + /** + * Converts a Genkit ToolDefinition to an Anthropic Tool object. + * Each runner implements this to return its specific API type. + */ + protected abstract toAnthropicTool(tool: ToolDefinition): any; + + /** + * Converts an Anthropic content block to a Genkit Part object. + * @param contentBlock The Anthropic content block to convert. + * @returns The converted Genkit Part object. + * @param event The Anthropic message stream event to convert. + * @returns The converted Genkit Part object if the event is a content block + * start or delta, otherwise undefined. + */ + protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { + if (contentBlock.type === 'tool_use') { + return { + toolRequest: { + ref: contentBlock.id, + name: contentBlock.name, + input: contentBlock.input, + }, + }; + } else if (contentBlock.type === 'text') { + return { text: contentBlock.text }; + } else if (contentBlock.type === 'thinking') { + return { text: contentBlock.thinking }; + } else if (contentBlock.type === 'redacted_thinking') { + return { text: contentBlock.data }; + } else { + // Handle unexpected content block types + // Log warning for debugging, but return empty text to avoid breaking the flow + const unknownType = (contentBlock as { type: string }).type; + console.warn( + `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` ); + return { text: '' }; } + } - // Check if the output is a media object or a string - const isMedia = this.isMediaObject(part.toolResponse?.output); - const isString = typeof part.toolResponse?.output === 'string'; - let base64Data; - if (isMedia) { - base64Data = this.extractDataFromBase64Url( - (part.toolResponse?.output as Media).url - ); - } else if (isString) { - base64Data = this.extractDataFromBase64Url( - part.toolResponse?.output as string - ); + /** + * Converts an Anthropic message stream event to a Genkit Part object. + */ + protected fromAnthropicContentBlockChunk( + event: MessageStreamEvent + ): Part | undefined { + if ( + event.type !== 'content_block_start' && + event.type !== 'content_block_delta' + ) { + return; } + const eventField = + event.type === 'content_block_start' ? 'content_block' : 'delta'; + return ['text', 'text_delta'].includes(event[eventField].type) + ? { + text: event[eventField].text, + } + : { + toolRequest: { + ref: event[eventField].id, + name: event[eventField].name, + input: event[eventField].input, + }, + }; + } - // Handle media content - if (base64Data) { - const resolvedMediaType: string | undefined = - (part.toolResponse?.output as Media)?.contentType ?? - base64Data?.contentType; - if ( - !resolvedMediaType || - !Object.values(MediaType).includes(resolvedMediaType as MediaType) - ) { - throw new Error(`Invalid media type: ${resolvedMediaType}`); + protected fromAnthropicStopReason( + reason: Message['stop_reason'] + ): ModelResponseData['finishReason'] { + switch (reason) { + case 'max_tokens': + return 'length'; + case 'end_turn': + // fall through + case 'stop_sequence': + // fall through + case 'tool_use': + return 'stop'; + case null: + return 'unknown'; + default: + 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, + }; + } + + /** + * Converts an Anthropic request to an Anthropic API request body. + * @param modelName The name of the Anthropic model to use. + * @param request The Genkit GenerateRequest to convert. + * @param stream Whether to stream the response. + * @param cacheSystemPrompt Whether to cache the system prompt. + * @returns The converted Anthropic API request body. + * @throws An error if an unsupported output format is requested. + */ + protected abstract toAnthropicRequestBody( + modelName: string, + request: GenerateRequest, + stream?: boolean, + cacheSystemPrompt?: boolean + ): TRequestBody; + + protected abstract createMessage( + body: TRequestBody, + abortSignal: AbortSignal + ): Promise; + + protected abstract streamMessages( + body: TRequestBody, + abortSignal: AbortSignal + ): MessageStreamLike; + + protected abstract toGenkitResponse(message: TMessage): GenerateResponseData; + + protected abstract toGenkitPart(event: TStreamEvent): Part | undefined; + + public async run( + request: GenerateRequest, + options: { + streamingRequested: boolean; + sendChunk: (chunk: GenerateResponseChunkData) => void; + abortSignal: AbortSignal; + } + ): Promise { + const { streamingRequested, sendChunk, abortSignal } = options; + + const body = this.toAnthropicRequestBody( + this.name, + request, + streamingRequested, + this.cacheSystemPrompt + ); + + if (streamingRequested) { + const stream = this.streamMessages(body, abortSignal); + for await (const event of stream) { + const part = this.toGenkitPart(event); + if (part) { + sendChunk({ + index: 0, + content: [part], + }); + } + } + const finalMessage = await stream.finalMessage(); + return this.toGenkitResponse(finalMessage); + } + + const response = await this.createMessage(body, abortSignal); + return this.toGenkitResponse(response); + } +} + +export class RegularRunner extends Runner< + Message, + MessageStreamEvent, + MessageCreateParams +> { + constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { + super(name, client, cacheSystemPrompt); + } + + protected toAnthropicRole( + role: Role, + toolMessageType?: 'tool_use' | 'tool_result' + ): 'user' | 'assistant' { + if (role === 'user') { + return 'user'; + } + if (role === 'model') { + return 'assistant'; + } + if (role === 'tool') { + return toolMessageType === 'tool_use' ? 'assistant' : 'user'; + } + throw new Error(`Unsupported genkit role: ${role}`); + } + + protected toAnthropicToolResponseContent( + part: Part + ): TextBlockParam | ImageBlockParam { + const output = part.toolResponse?.output ?? {}; + + // Handle Media objects (images returned by tools) + if (this.isMediaObject(output)) { + const { data, contentType } = + this.extractDataFromBase64Url(output.url) ?? {}; + if (data && contentType && isMediaType(contentType)) { + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: contentType, + }, + }; } - const mediaTypeValue: MediaType = resolvedMediaType as MediaType; + } + // Handle string outputs - check if it's a data URL + if (typeof output === 'string') { + // Check if string is a data URL (e.g., "data:image/gif;base64,...") + if (this.isDataUrl(output)) { + const { data, contentType } = + this.extractDataFromBase64Url(output) ?? {}; + if (data && contentType && isMediaType(contentType)) { + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: contentType, + }, + }; + } + } + // Regular string output return { - type: 'image', - source: { - type: 'base64', - data: base64Data.data, - media_type: mediaTypeValue, - }, + type: 'text', + text: output, }; } - // Handle text content + // Handle other outputs by stringifying return { type: 'text', - text: isString - ? (part.toolResponse?.output as string) - : JSON.stringify(part.toolResponse?.output), + text: JSON.stringify(output), }; } - /** - * Converts a Genkit Part to the corresponding Anthropic TextBlock, ImageBlockParam, etc. - */ protected toAnthropicMessageContent( part: Part ): - | TextBlock + | TextBlockParam | ImageBlockParam | DocumentBlockParam | ToolUseBlockParam | ToolResultBlockParam { if (part.text) { - // Anthropic SDK expects citations field to be explicitly set to null - // when not provided (tests confirm this pattern is correct) return { type: 'text', text: part.text, @@ -250,10 +483,10 @@ export abstract class Runner { if (!resolvedMediaType) { throw new Error('Media type is required but was not provided'); } - if (!Object.values(MediaType).includes(resolvedMediaType as MediaType)) { + if (!isMediaType(resolvedMediaType)) { throw new Error(`Unsupported media type: ${resolvedMediaType}`); } - const mediaTypeValue: MediaType = resolvedMediaType as MediaType; + const mediaTypeValue: MediaType = resolvedMediaType; return { type: 'image', @@ -300,11 +533,6 @@ export abstract class Runner { ); } - /** - * Converts a Genkit MessageData array to Anthropic system message and MessageParam array. - * @param messages The Genkit MessageData array to convert. - * @returns An object containing the optional Anthropic system message and the array of Anthropic MessageParam objects. - */ protected toAnthropicMessages(messages: MessageData[]): { system?: string; messages: MessageParam[]; @@ -317,7 +545,9 @@ export abstract class Runner { const anthropicMsgs: MessageParam[] = []; for (const message of messagesToIterate) { const msg = new GenkitMessage(message); - const content = msg.content.map(this.toAnthropicMessageContent); + const content = msg.content.map((part) => + this.toAnthropicMessageContent(part) + ); const toolMessageType = content.find( (c) => c.type === 'tool_use' || c.type === 'tool_result' ) as ToolUseBlockParam | ToolResultBlockParam; @@ -330,11 +560,6 @@ export abstract class Runner { return { system, messages: anthropicMsgs }; } - /** - * Converts a Genkit ToolDefinition to an Anthropic Tool object. - * @param tool The Genkit ToolDefinition to convert. - * @returns The converted Anthropic Tool object. - */ protected toAnthropicTool(tool: ToolDefinition): Tool { return { name: tool.name, @@ -343,115 +568,6 @@ export abstract class Runner { }; } - /** - * Converts an Anthropic content block to a Genkit Part object. - * @param contentBlock The Anthropic content block to convert. - * @returns The converted Genkit Part object. - * @param event The Anthropic message stream event to convert. - * @returns The converted Genkit Part object if the event is a content block - * start or delta, otherwise undefined. - */ - protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { - if (contentBlock.type === 'tool_use') { - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }; - } else if (contentBlock.type === 'text') { - return { text: contentBlock.text }; - } else if (contentBlock.type === 'thinking') { - return { text: contentBlock.thinking }; - } else if (contentBlock.type === 'redacted_thinking') { - return { text: contentBlock.data }; - } else { - // Handle unexpected content block types - // Log warning for debugging, but return empty text to avoid breaking the flow - const unknownType = (contentBlock as { type: string }).type; - console.warn( - `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` - ); - return { text: '' }; - } - } - - /** - * Converts an Anthropic message stream event to a Genkit Part object. - */ - protected fromAnthropicContentBlockChunk( - event: MessageStreamEvent - ): Part | undefined { - if ( - event.type !== 'content_block_start' && - event.type !== 'content_block_delta' - ) { - return; - } - const eventField = - event.type === 'content_block_start' ? 'content_block' : 'delta'; - return ['text', 'text_delta'].includes(event[eventField].type) - ? { - text: event[eventField].text, - } - : { - toolRequest: { - ref: event[eventField].id, - name: event[eventField].name, - input: event[eventField].input, - }, - }; - } - - protected fromAnthropicStopReason( - reason: Message['stop_reason'] - ): ModelResponseData['finishReason'] { - switch (reason) { - case 'max_tokens': - return 'length'; - case 'end_turn': - // fall through - case 'stop_sequence': - // fall through - case 'tool_use': - return 'stop'; - case null: - return 'unknown'; - default: - return 'other'; - } - } - - protected fromAnthropicResponse(response: Message): GenerateResponseData { - return { - candidates: [ - { - index: 0, - finishReason: this.fromAnthropicStopReason(response.stop_reason), - message: { - role: 'model', - content: response.content.map(this.fromAnthropicContentBlock), - }, - }, - ], - usage: { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - }, - custom: response, - }; - } - - /** - * Converts an Anthropic request to an Anthropic API request body. - * @param modelName The name of the Anthropic model to use. - * @param request The Genkit GenerateRequest to convert. - * @param stream Whether to stream the response. - * @param cacheSystemPrompt Whether to cache the system prompt. - * @returns The converted Anthropic API request body. - * @throws An error if an unsupported output format is requested. - */ protected toAnthropicRequestBody( modelName: string, request: GenerateRequest, @@ -475,7 +591,7 @@ export abstract class Runner { ] : system, messages, - tools: request.tools?.map(this.toAnthropicTool), + tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, model: mappedModelName, @@ -504,102 +620,459 @@ export abstract class Runner { return cleanedBody; } - public abstract run( - request: GenerateRequest, - options: { - streamingRequested: boolean; - sendChunk: (chunk: GenerateResponseChunkData) => void; - abortSignal: AbortSignal; - } - ): Promise; + protected createMessage( + body: MessageCreateParams, + abortSignal: AbortSignal + ): Promise { + return this.client.messages.create(body, { + signal: abortSignal, + }) as Promise; + } + + protected streamMessages( + body: MessageCreateParams, + abortSignal: AbortSignal + ): MessageStreamLike { + return this.client.messages.stream(body, { + signal: abortSignal, + }) as MessageStream; + } + + protected toGenkitResponse(message: Message): GenerateResponseData { + return this.fromAnthropicResponse(message); + } + + protected toGenkitPart(event: MessageStreamEvent): Part | undefined { + return this.fromAnthropicContentBlockChunk(event); + } } -export class BetaRunner extends Runner { +type BetaToolUseLike = + | BetaToolUseBlock + | BetaServerToolUseBlock + | BetaMCPToolUseBlock; + +export class BetaRunner extends Runner< + BetaMessage, + BetaRawMessageStreamEvent, + BetaMessageCreateParams +> { constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { super(name, client, cacheSystemPrompt); } - public async run( - request: GenerateRequest, - options: { - streamingRequested: boolean; - sendChunk: (chunk: GenerateResponseChunkData) => void; - abortSignal: AbortSignal; + protected toAnthropicRole( + role: Role, + toolMessageType?: 'tool_use' | 'tool_result' + ): 'user' | 'assistant' { + if (role === 'user') { + return 'user'; } - ): Promise { - const { streamingRequested, sendChunk, abortSignal } = options; + if (role === 'model') { + return 'assistant'; + } + if (role === 'tool') { + return toolMessageType === 'tool_use' ? 'assistant' : 'user'; + } + throw new Error(`Unsupported genkit role: ${role}`); + } - let response: Message; - const body = this.toAnthropicRequestBody( - this.name, - request, - streamingRequested, - this.cacheSystemPrompt - ); + protected toAnthropicToolResponseContent( + part: Part + ): + | BetaTextBlockParam + | BetaImageBlockParam + | BetaRequestDocumentBlock + | BetaSearchResultBlockParam { + const output = part.toolResponse?.output ?? {}; - if (streamingRequested) { - const stream = this.client.beta.messages.stream(body, { - signal: abortSignal, - }); - for await (const chunk of stream) { - const c = this.fromAnthropicContentBlockChunk(chunk); - if (c) { - sendChunk({ - index: 0, - content: [c], - }); + // Handle Media objects (images returned by tools) + if (this.isMediaObject(output)) { + const { data, contentType } = + this.extractDataFromBase64Url(output.url) ?? {}; + if (data && contentType && isMediaType(contentType)) { + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: contentType, + }, + } as BetaImageBlockParam; + } + } + + // Handle string outputs - check if it's a data URL + if (typeof output === 'string') { + // Check if string is a data URL (e.g., "data:image/gif;base64,...") + if (this.isDataUrl(output)) { + const { data, contentType } = + this.extractDataFromBase64Url(output) ?? {}; + if (data && contentType && isMediaType(contentType)) { + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: contentType, + }, + } as BetaImageBlockParam; } } - response = (await stream.finalMessage()) as Message; - } else { - response = (await this.client.beta.messages.create(body, { - signal: abortSignal, - })) as Message; + // Regular string output + return { + type: 'text', + text: output, + } as BetaTextBlockParam; } - return this.fromAnthropicResponse(response); + + // Handle other outputs by stringifying + return { + type: 'text', + text: JSON.stringify(output), + } as BetaTextBlockParam; } -} -export class RegularRunner extends Runner { - constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { - super(name, client, cacheSystemPrompt); + protected toAnthropicMessageContent(part: Part): BetaContentBlockParam { + if (part.text) { + return { + type: 'text', + text: part.text, + }; + } + if (part.media) { + const resolvedContentType = part.media.contentType; + + // Check if this is a PDF document + if (resolvedContentType === 'application/pdf') { + const url = part.media.url; + + if (this.isDataUrl(url)) { + // Extract base64 data and MIME type from data URL + const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); + if (!base64Match) { + throw new Error( + `Invalid PDF data URL format: ${url.substring(0, 50)}...` + ); + } + + const extractedContentType = base64Match[1]; + const base64Data = base64Match[2]; + + // Verify the extracted type matches PDF + if (extractedContentType !== 'application/pdf') { + throw new Error( + `PDF contentType mismatch: expected application/pdf, got ${extractedContentType}` + ); + } + + return { + type: 'document', + source: { + type: 'base64', + media_type: 'application/pdf', + data: base64Data, + }, + }; + } else { + // File URL (HTTP/HTTPS/other) + return { + type: 'document', + source: { + type: 'url', + url: url, + }, + }; + } + } + + // Handle non-PDF media (images) + const { data, contentType } = + this.extractDataFromBase64Url(part.media.url) ?? {}; + if (!data || !contentType) { + throw new Error( + `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( + part.media + )}.` + ); + } + + // Resolve and validate the media type + const resolvedMediaType: string | undefined = + part.media.contentType ?? contentType; + if (!resolvedMediaType) { + throw new Error('Media type is required but was not provided'); + } + if (!isMediaType(resolvedMediaType)) { + throw new Error(`Unsupported media type: ${resolvedMediaType}`); + } + const mediaTypeValue: MediaType = resolvedMediaType; + + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: mediaTypeValue, + }, + }; + } + if (part.toolRequest) { + if (!part.toolRequest.ref) { + throw new Error( + `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolRequest + )}` + ); + } + return { + type: 'tool_use', + id: part.toolRequest.ref, + name: part.toolRequest.name, + input: part.toolRequest.input, + }; + } + if (part.toolResponse) { + if (!part.toolResponse.ref) { + throw new Error( + `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolResponse + )}` + ); + } + const betaResult: BetaToolResultBlockParam = { + type: 'tool_result', + tool_use_id: part.toolResponse.ref, + content: [this.toAnthropicToolResponseContent(part)], + }; + return betaResult; + } + throw new Error( + `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( + part + )}.` + ); } - public async run( + protected toAnthropicMessages(messages: MessageData[]): { + system?: string; + messages: BetaMessageParam[]; + } { + const system = + messages[0]?.role === 'system' + ? messages[0].content?.[0]?.text + : undefined; + const messagesToIterate = system ? messages.slice(1) : messages; + const anthropicMsgs: BetaMessageParam[] = []; + for (const message of messagesToIterate) { + const msg = new GenkitMessage(message); + const content = msg.content.map((part) => + this.toAnthropicMessageContent(part) + ); + const toolMessageType = content.find( + (c) => c.type === 'tool_use' || c.type === 'tool_result' + ); + const role = this.toAnthropicRole( + message.role, + toolMessageType?.type as 'tool_use' | 'tool_result' | undefined + ); + anthropicMsgs.push({ + role: role, + content, + }); + } + return { system, messages: anthropicMsgs }; + } + + protected toAnthropicTool(tool: ToolDefinition): any { + return { + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema as Tool.InputSchema, + }; + } + + protected createMessage( + body: BetaMessageCreateParams, + abortSignal: AbortSignal + ): Promise { + return this.client.beta.messages.create(body, { + signal: abortSignal, + }) as Promise; + } + + protected streamMessages( + body: BetaMessageCreateParams, + abortSignal: AbortSignal + ): MessageStreamLike { + return this.client.beta.messages.stream(body, { + signal: abortSignal, + }) as BetaMessageStream; + } + + protected toAnthropicRequestBody( + modelName: string, request: GenerateRequest, - options: { - streamingRequested: boolean; - sendChunk: (chunk: GenerateResponseChunkData) => void; - abortSignal: AbortSignal; + stream?: boolean, + cacheSystemPrompt?: boolean + ): BetaMessageCreateParams { + // Use supported model ref if available for version mapping, otherwise use modelName directly + const model = SUPPORTED_CLAUDE_MODELS[modelName]; + const { system, messages } = this.toAnthropicMessages(request.messages); + const mappedModelName = + request.config?.version ?? model?.version ?? modelName; + + // Convert system to beta format with cache control if needed + const betaSystem = + system === undefined + ? undefined + : cacheSystemPrompt + ? [ + { + type: 'text' as const, + text: system, + cache_control: { type: 'ephemeral' as const }, + }, + ] + : system; + + const body: BetaMessageCreateParams = { + model: mappedModelName, + max_tokens: + request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, + messages: messages, + }; + + if (betaSystem !== undefined) { + body.system = betaSystem; + } + if (stream !== undefined) { + body.stream = stream as false; + } + if (request.config?.stopSequences !== undefined) { + body.stop_sequences = request.config.stopSequences; + } + if (request.config?.temperature !== undefined) { + body.temperature = request.config.temperature; + } + if (request.config?.topK !== undefined) { + body.top_k = request.config.topK; + } + if (request.config?.topP !== undefined) { + body.top_p = request.config.topP; + } + if (request.config?.tool_choice !== undefined) { + body.tool_choice = request.config + .tool_choice as BetaMessageCreateParams['tool_choice']; + } + if (request.config?.metadata !== undefined) { + body.metadata = request.config + .metadata as BetaMessageCreateParams['metadata']; + } + if (request.tools) { + body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); } - ): Promise { - const { streamingRequested, sendChunk, abortSignal } = options; - let response: Message; - const body = this.toAnthropicRequestBody( - this.name, - request, - streamingRequested, - this.cacheSystemPrompt - ); + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } - if (streamingRequested) { - const stream = this.client.messages.stream(body, { signal: abortSignal }); - for await (const chunk of stream) { - const c = this.fromAnthropicContentBlockChunk(chunk); - if (c) { - sendChunk({ - index: 0, - content: [c], - }); - } + return body; + } + + protected toGenkitResponse(message: BetaMessage): GenerateResponseData { + return { + candidates: [ + { + index: 0, + finishReason: this.fromBetaStopReason(message.stop_reason), + message: { + role: 'model', + content: message.content.map((block) => + this.fromBetaContentBlock(block) + ), + }, + }, + ], + usage: { + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + }, + custom: message, + }; + } + + protected toGenkitPart(event: BetaRawMessageStreamEvent): Part | undefined { + if (event.type === 'content_block_start') { + return this.fromBetaContentBlock(event.content_block); + } + if (event.type === 'content_block_delta') { + if (event.delta.type === 'text_delta') { + return { text: event.delta.text }; } - response = (await stream.finalMessage()) as Message; - } else { - response = (await this.client.messages.create(body, { - signal: abortSignal, - })) as Message; + if (event.delta.type === 'thinking_delta') { + return { text: event.delta.thinking }; + } + return undefined; + } + return undefined; + } + + private fromBetaContentBlock(contentBlock: BetaContentBlock): Part { + switch (contentBlock.type) { + case 'tool_use': + case 'server_tool_use': + case 'mcp_tool_use': + return { + toolRequest: { + ref: contentBlock.id, + name: this.betaToolName(contentBlock), + input: contentBlock.input, + }, + }; + case 'text': + return { text: contentBlock.text }; + case 'thinking': + return { text: contentBlock.thinking }; + case 'redacted_thinking': + return { text: contentBlock.data }; + default: { + const unknownType = (contentBlock as { type: string }).type; + console.warn( + `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` + ); + return { text: '' }; + } + } + } + + private betaToolName(block: BetaToolUseLike): string { + if ('server_name' in block && block.server_name) { + return `${block.server_name}/${block.name}`; + } + return block.name ?? 'unknown_tool'; + } + + private fromBetaStopReason( + reason: BetaStopReason | null + ): ModelResponseData['finishReason'] { + switch (reason) { + case 'max_tokens': + case 'model_context_window_exceeded': + return 'length'; + case 'end_turn': + case 'stop_sequence': + case 'tool_use': + case 'pause_turn': + return 'stop'; + case null: + return 'unknown'; + case 'refusal': + return 'other'; + default: + return 'other'; } - return this.fromAnthropicResponse(response); } } diff --git a/js/plugins/anthropic/tests/converters_test.ts b/js/plugins/anthropic/tests/converters_test.ts index 0d91d91b37..94de449fdc 100644 --- a/js/plugins/anthropic/tests/converters_test.ts +++ b/js/plugins/anthropic/tests/converters_test.ts @@ -32,20 +32,9 @@ import type { import type { CandidateData, ToolDefinition } from 'genkit/model'; import { describe, it, mock } from 'node:test'; -import type { AnthropicConfigSchema } from '../src/claude.js'; -import { - claudeModel, - claudeRunner, - fromAnthropicContentBlockChunk, - fromAnthropicResponse, - fromAnthropicStopReason, - toAnthropicMessageContent, - toAnthropicMessages, - toAnthropicRequestBody, - toAnthropicRole, - toAnthropicTool, - toAnthropicToolResponseContent, -} from '../src/claude.js'; +import { claudeModel, claudeRunner } from '../src/models.js'; +import { RegularRunner } from '../src/runner.js'; +import type { AnthropicConfigSchema } from '../src/types.js'; import { createMockAnthropicClient, mockContentBlockStart, @@ -53,6 +42,50 @@ import { mockToolUseChunk, } from './mocks/anthropic-client.js'; +// Test helper: Create a RegularRunner instance for testing converter methods +const mockClient = createMockAnthropicClient(); +const testRunner = new RegularRunner('test-model', mockClient); + +// Helper functions to access protected methods for testing +const toAnthropicRole = ( + role: Role, + toolMessageType?: 'tool_use' | 'tool_result' +) => (testRunner as any).toAnthropicRole(role, toolMessageType); + +const toAnthropicToolResponseContent = (part: Part) => + (testRunner as any).toAnthropicToolResponseContent(part); + +const toAnthropicMessageContent = (part: Part) => + (testRunner as any).toAnthropicMessageContent(part); + +const toAnthropicMessages = (messages: MessageData[]) => + (testRunner as any).toAnthropicMessages(messages); + +const toAnthropicTool = (tool: ToolDefinition) => + (testRunner as any).toAnthropicTool(tool); + +const toAnthropicRequestBody = ( + modelName: string, + request: GenerateRequest, + stream?: boolean, + cacheSystemPrompt?: boolean +) => + (testRunner as any).toAnthropicRequestBody( + modelName, + request, + stream, + cacheSystemPrompt + ); + +const fromAnthropicContentBlockChunk = (event: MessageStreamEvent) => + (testRunner as any).fromAnthropicContentBlockChunk(event); + +const fromAnthropicStopReason = (reason: Message['stop_reason']) => + (testRunner as any).fromAnthropicStopReason(reason); + +const fromAnthropicResponse = (message: Message) => + (testRunner as any).fromAnthropicResponse(message); + describe('toAnthropicRole', () => { const testCases: { genkitRole: Role; @@ -96,18 +129,19 @@ describe('toAnthropicRole', () => { it('should throw an error for unknown roles', () => { assert.throws( () => toAnthropicRole('unknown' as Role), - /role unknown doesn't map to an Anthropic role\./ + /Unsupported genkit role: unknown/ ); }); }); describe('toAnthropicToolResponseContent', () => { - it('should throw an error for unknown parts', () => { + it('should not throw for parts without toolResponse', () => { + // toAnthropicToolResponseContent expects part.toolResponse to exist + // but will just return stringified undefined/empty object if not const part: Part = { data: 'hi' } as Part; - assert.throws( - () => toAnthropicToolResponseContent(part), - /Invalid genkit part provided to toAnthropicToolResponseContent: {"data":"hi"}/ - ); + const result = toAnthropicToolResponseContent(part); + assert.ok(result); + assert.strictEqual(result.type, 'text'); }); }); @@ -1058,6 +1092,36 @@ describe('claudeRunner', () => { }, ]); }); + + it('should use beta API when beta.enabled is true', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: { + content: [{ type: 'text', text: 'response', citations: null }], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }); + + const runner = claudeRunner('claude-3-5-haiku', mockClient); + const abortSignal = new AbortController().signal; + await runner( + { + messages: [], + config: { beta: { enabled: true } }, + }, + { streamingRequested: false, sendChunk: () => {}, abortSignal } + ); + + const betaCreateStub = mockClient.beta.messages.create as any; + assert.strictEqual(betaCreateStub.mock.calls.length, 1); + + const regularCreateStub = mockClient.messages.create as any; + assert.strictEqual(regularCreateStub.mock.calls.length, 0); + }); }); describe('claudeModel', () => { diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index df86c6c44a..d115115e11 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -17,8 +17,8 @@ import * as assert from 'assert'; import { genkit } from 'genkit'; import { describe, it } from 'node:test'; -import { SUPPORTED_CLAUDE_MODELS } from '../src/claude.js'; import anthropic from '../src/index.js'; +import { SUPPORTED_CLAUDE_MODELS } from '../src/models.js'; import { PluginOptions, __testClient } from '../src/types.js'; import { createMockAnthropicClient } from './mocks/anthropic-client.js'; diff --git a/js/plugins/anthropic/tests/mocks/anthropic-client.ts b/js/plugins/anthropic/tests/mocks/anthropic-client.ts index 86fb6091c8..fd7e1bc4cb 100644 --- a/js/plugins/anthropic/tests/mocks/anthropic-client.ts +++ b/js/plugins/anthropic/tests/mocks/anthropic-client.ts @@ -15,6 +15,10 @@ */ import type Anthropic from '@anthropic-ai/sdk'; +import type { + BetaMessage, + BetaRawMessageStreamEvent, +} from '@anthropic-ai/sdk/resources/beta/messages.mjs'; import type { Message, MessageStreamEvent, @@ -42,6 +46,7 @@ export function createMockAnthropicClient( ...mockDefaultMessage(), ...options.messageResponse, }; + const betaMessageResponse = toBetaMessage(messageResponse); // Support sequential responses for tool calling workflows let callCount = 0; @@ -61,6 +66,23 @@ export function createMockAnthropicClient( }) : mock.fn(async () => messageResponse); + let betaCallCount = 0; + const betaCreateStub = options.shouldError + ? mock.fn(async () => { + throw options.shouldError; + }) + : options.sequentialResponses + ? mock.fn(async () => { + const response = + options.sequentialResponses![betaCallCount] || messageResponse; + betaCallCount++; + return toBetaMessage({ + ...mockDefaultMessage(), + ...response, + }); + }) + : mock.fn(async () => betaMessageResponse); + const streamStub = options.shouldError ? mock.fn(() => { throw options.shouldError; @@ -79,6 +101,26 @@ export function createMockAnthropicClient( ); }); + const betaStreamStub = options.shouldError + ? mock.fn(() => { + throw options.shouldError; + }) + : mock.fn((body: any, opts?: { signal?: AbortSignal }) => { + if (opts?.signal?.aborted) { + throw new Error('AbortError'); + } + const betaChunks = (options.streamChunks || []).map((chunk) => + toBetaStreamEvent(chunk) + ); + return createMockStream( + betaChunks, + toBetaMessage(messageResponse), + options.streamErrorAfterChunk, + options.streamError, + opts?.signal + ); + }); + const listStub = options.shouldError ? mock.fn(async () => { throw options.shouldError; @@ -95,15 +137,21 @@ export function createMockAnthropicClient( models: { list: listStub, }, + beta: { + messages: { + create: betaCreateStub, + stream: betaStreamStub, + }, + }, } as unknown as Anthropic; } /** * Creates a mock async iterable stream for streaming responses */ -function createMockStream( - chunks: MessageStreamEvent[], - finalMsg: Message, +function createMockStream( + chunks: TEventType[], + finalMsg: TMessageType, errorAfterChunk?: number, streamError?: Error, abortSignal?: AbortSignal @@ -130,9 +178,9 @@ function createMockStream( } if (index < chunks.length) { - return { value: chunks[index++], done: false }; + return { value: chunks[index++] as TEventType, done: false }; } - return { value: undefined, done: true }; + return { value: undefined as unknown as TEventType, done: true }; }, }; }, @@ -143,7 +191,7 @@ function createMockStream( error.name = 'AbortError'; throw error; } - return finalMsg; + return finalMsg as TMessageType; }, }; } @@ -316,3 +364,26 @@ export function mockMessageWithContent( stop_reason: 'end_turn', }; } + +function toBetaMessage(message: Message): BetaMessage { + return { + ...message, + container: null, + context_management: null, + usage: { + cache_creation: null, + cache_creation_input_tokens: message.usage.cache_creation_input_tokens, + cache_read_input_tokens: message.usage.cache_read_input_tokens, + input_tokens: message.usage.input_tokens, + output_tokens: message.usage.output_tokens, + server_tool_use: null, + service_tier: null, + }, + }; +} + +function toBetaStreamEvent( + event: MessageStreamEvent +): BetaRawMessageStreamEvent { + return event as unknown as BetaRawMessageStreamEvent; +} From 85e79c1646cbc65b36d9b324bfc5e99aa8067347 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 10 Nov 2025 14:52:36 +0000 Subject: [PATCH 16/51] refactor(anthropic): clean up classes and types a bit --- js/plugins/anthropic/src/models.ts | 4 +- js/plugins/anthropic/src/runner.ts | 1078 ----------------- js/plugins/anthropic/src/runner/base.ts | 486 ++++++++ js/plugins/anthropic/src/runner/beta.ts | 315 +++++ js/plugins/anthropic/src/runner/index.ts | 19 + js/plugins/anthropic/src/runner/runner.ts | 213 ++++ js/plugins/anthropic/src/types.ts | 26 +- js/plugins/anthropic/tests/converters_test.ts | 55 +- .../anthropic/tests/integration_test.ts | 6 + .../anthropic/tests/mocks/anthropic-client.ts | 32 +- 10 files changed, 1111 insertions(+), 1123 deletions(-) delete mode 100644 js/plugins/anthropic/src/runner.ts create mode 100644 js/plugins/anthropic/src/runner/base.ts create mode 100644 js/plugins/anthropic/src/runner/beta.ts create mode 100644 js/plugins/anthropic/src/runner/index.ts create mode 100644 js/plugins/anthropic/src/runner/runner.ts diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 620cdedeb8..8dcdafbb15 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -26,7 +26,7 @@ import type { GenerateResponseChunkData, ModelAction } from 'genkit/model'; import { modelRef } from 'genkit/model'; import { model } from 'genkit/plugin'; -import { BetaRunner, RegularRunner } from './runner.js'; +import { BetaRunner, Runner } from './runner/index.js'; import { AnthropicConfigSchema } from './types.js'; export const claude4Sonnet = modelRef({ @@ -200,7 +200,7 @@ export function claudeRunner( const isBeta = request.config?.beta?.enabled ?? false; const api = isBeta ? new BetaRunner(name, client, cacheSystemPrompt) - : new RegularRunner(name, client, cacheSystemPrompt); + : new Runner(name, client, cacheSystemPrompt); return api.run(request, { streamingRequested, sendChunk, abortSignal }); }; } diff --git a/js/plugins/anthropic/src/runner.ts b/js/plugins/anthropic/src/runner.ts deleted file mode 100644 index 977c0eb9a8..0000000000 --- a/js/plugins/anthropic/src/runner.ts +++ /dev/null @@ -1,1078 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Anthropic } from '@anthropic-ai/sdk'; -import type { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream'; -import type { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream'; -import type { - BetaContentBlock, - BetaContentBlockParam, - BetaImageBlockParam, - BetaMCPToolUseBlock, - BetaMessage, - MessageCreateParams as BetaMessageCreateParams, - BetaMessageParam, - BetaRawMessageStreamEvent, - BetaRequestDocumentBlock, - BetaSearchResultBlockParam, - BetaServerToolUseBlock, - BetaStopReason, - BetaTextBlockParam, - BetaToolResultBlockParam, - BetaToolUseBlock, -} from '@anthropic-ai/sdk/resources/beta/messages'; -import type { - ContentBlock, - DocumentBlockParam, - ImageBlockParam, - Message, - MessageCreateParams, - MessageParam, - MessageStreamEvent, - TextBlockParam, - Tool, - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages'; -import type { - GenerateRequest, - GenerateResponseChunkData, - GenerateResponseData, - MessageData, - ModelResponseData, - Part, - Role, -} from 'genkit'; -import { Message as GenkitMessage } from 'genkit'; -import type { ToolDefinition } from 'genkit/model'; - -import { SUPPORTED_CLAUDE_MODELS } from './models'; -import { AnthropicConfigSchema, Media, MediaType } from './types'; - -type MessageStreamLike = AsyncIterable & { - finalMessage(): Promise; -}; - -/** - * Type guard to check if a value is a valid MediaType. - */ -function isMediaType(value: string): value is MediaType { - return Object.values(MediaType).includes(value as MediaType); -} - -/** - * Type guard to check if an object is a Media object. - */ -function isMedia(obj: unknown): obj is Media { - return ( - typeof obj === 'object' && - obj !== null && - 'url' in obj && - typeof (obj as Media).url === 'string' - ); -} - -export abstract class Runner { - protected name: string; - protected client: Anthropic; - protected cacheSystemPrompt?: boolean; - /** - * Default maximum output tokens for Claude models when not specified in the request. - */ - protected readonly DEFAULT_MAX_OUTPUT_TOKENS = 4096; - - constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { - this.name = name; - this.client = client; - this.cacheSystemPrompt = cacheSystemPrompt; - } - - /** - * Converts a Genkit role to the corresponding Anthropic role. - */ - protected abstract toAnthropicRole( - role: Role, - toolMessageType?: 'tool_use' | 'tool_result' - ): 'user' | 'assistant'; - - protected isMediaObject(obj: unknown): obj is Media { - return isMedia(obj); - } - - /** - * Checks if a URL is a data URL (starts with 'data:'). - * This follows the Google GenAI plugin pattern for distinguishing inline data from file references. - */ - protected isDataUrl(url: string): boolean { - return url.startsWith('data:'); - } - - protected extractDataFromBase64Url( - url: string - ): { data: string; contentType: string } | null { - const match = url.match(/^data:([^;]+);base64,(.+)$/); - return ( - match && { - contentType: match[1], - data: match[2], - } - ); - } - - /** - * Converts a Genkit message Part to the corresponding Anthropic tool response content. - * Each runner implements this to return its specific API type. - */ - protected abstract toAnthropicToolResponseContent(part: Part): any; - - /** - * Converts a Genkit Part to the corresponding Anthropic content block. - * Each runner implements this to return its specific API type. - */ - protected abstract toAnthropicMessageContent(part: Part): any; - - /** - * Converts a Genkit MessageData array to Anthropic system and messages. - * Each runner implements this to return its specific API types. - */ - protected abstract toAnthropicMessages(messages: MessageData[]): { - system?: string; - messages: any[]; - }; - - /** - * Converts a Genkit ToolDefinition to an Anthropic Tool object. - * Each runner implements this to return its specific API type. - */ - protected abstract toAnthropicTool(tool: ToolDefinition): any; - - /** - * Converts an Anthropic content block to a Genkit Part object. - * @param contentBlock The Anthropic content block to convert. - * @returns The converted Genkit Part object. - * @param event The Anthropic message stream event to convert. - * @returns The converted Genkit Part object if the event is a content block - * start or delta, otherwise undefined. - */ - protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { - if (contentBlock.type === 'tool_use') { - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }; - } else if (contentBlock.type === 'text') { - return { text: contentBlock.text }; - } else if (contentBlock.type === 'thinking') { - return { text: contentBlock.thinking }; - } else if (contentBlock.type === 'redacted_thinking') { - return { text: contentBlock.data }; - } else { - // Handle unexpected content block types - // Log warning for debugging, but return empty text to avoid breaking the flow - const unknownType = (contentBlock as { type: string }).type; - console.warn( - `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` - ); - return { text: '' }; - } - } - - /** - * Converts an Anthropic message stream event to a Genkit Part object. - */ - protected fromAnthropicContentBlockChunk( - event: MessageStreamEvent - ): Part | undefined { - if ( - event.type !== 'content_block_start' && - event.type !== 'content_block_delta' - ) { - return; - } - const eventField = - event.type === 'content_block_start' ? 'content_block' : 'delta'; - return ['text', 'text_delta'].includes(event[eventField].type) - ? { - text: event[eventField].text, - } - : { - toolRequest: { - ref: event[eventField].id, - name: event[eventField].name, - input: event[eventField].input, - }, - }; - } - - protected fromAnthropicStopReason( - reason: Message['stop_reason'] - ): ModelResponseData['finishReason'] { - switch (reason) { - case 'max_tokens': - return 'length'; - case 'end_turn': - // fall through - case 'stop_sequence': - // fall through - case 'tool_use': - return 'stop'; - case null: - return 'unknown'; - default: - 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, - }; - } - - /** - * Converts an Anthropic request to an Anthropic API request body. - * @param modelName The name of the Anthropic model to use. - * @param request The Genkit GenerateRequest to convert. - * @param stream Whether to stream the response. - * @param cacheSystemPrompt Whether to cache the system prompt. - * @returns The converted Anthropic API request body. - * @throws An error if an unsupported output format is requested. - */ - protected abstract toAnthropicRequestBody( - modelName: string, - request: GenerateRequest, - stream?: boolean, - cacheSystemPrompt?: boolean - ): TRequestBody; - - protected abstract createMessage( - body: TRequestBody, - abortSignal: AbortSignal - ): Promise; - - protected abstract streamMessages( - body: TRequestBody, - abortSignal: AbortSignal - ): MessageStreamLike; - - protected abstract toGenkitResponse(message: TMessage): GenerateResponseData; - - protected abstract toGenkitPart(event: TStreamEvent): Part | undefined; - - public async run( - request: GenerateRequest, - options: { - streamingRequested: boolean; - sendChunk: (chunk: GenerateResponseChunkData) => void; - abortSignal: AbortSignal; - } - ): Promise { - const { streamingRequested, sendChunk, abortSignal } = options; - - const body = this.toAnthropicRequestBody( - this.name, - request, - streamingRequested, - this.cacheSystemPrompt - ); - - if (streamingRequested) { - const stream = this.streamMessages(body, abortSignal); - for await (const event of stream) { - const part = this.toGenkitPart(event); - if (part) { - sendChunk({ - index: 0, - content: [part], - }); - } - } - const finalMessage = await stream.finalMessage(); - return this.toGenkitResponse(finalMessage); - } - - const response = await this.createMessage(body, abortSignal); - return this.toGenkitResponse(response); - } -} - -export class RegularRunner extends Runner< - Message, - MessageStreamEvent, - MessageCreateParams -> { - constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { - super(name, client, cacheSystemPrompt); - } - - protected toAnthropicRole( - role: Role, - toolMessageType?: 'tool_use' | 'tool_result' - ): 'user' | 'assistant' { - if (role === 'user') { - return 'user'; - } - if (role === 'model') { - return 'assistant'; - } - if (role === 'tool') { - return toolMessageType === 'tool_use' ? 'assistant' : 'user'; - } - throw new Error(`Unsupported genkit role: ${role}`); - } - - protected toAnthropicToolResponseContent( - part: Part - ): TextBlockParam | ImageBlockParam { - const output = part.toolResponse?.output ?? {}; - - // Handle Media objects (images returned by tools) - if (this.isMediaObject(output)) { - const { data, contentType } = - this.extractDataFromBase64Url(output.url) ?? {}; - if (data && contentType && isMediaType(contentType)) { - return { - type: 'image', - source: { - type: 'base64', - data, - media_type: contentType, - }, - }; - } - } - - // Handle string outputs - check if it's a data URL - if (typeof output === 'string') { - // Check if string is a data URL (e.g., "data:image/gif;base64,...") - if (this.isDataUrl(output)) { - const { data, contentType } = - this.extractDataFromBase64Url(output) ?? {}; - if (data && contentType && isMediaType(contentType)) { - return { - type: 'image', - source: { - type: 'base64', - data, - media_type: contentType, - }, - }; - } - } - // Regular string output - return { - type: 'text', - text: output, - }; - } - - // Handle other outputs by stringifying - return { - type: 'text', - text: JSON.stringify(output), - }; - } - - protected toAnthropicMessageContent( - part: Part - ): - | TextBlockParam - | ImageBlockParam - | DocumentBlockParam - | ToolUseBlockParam - | ToolResultBlockParam { - if (part.text) { - return { - type: 'text', - text: part.text, - citations: null, - }; - } - if (part.media) { - const resolvedContentType = part.media.contentType; - - // Check if this is a PDF document - if (resolvedContentType === 'application/pdf') { - const url = part.media.url; - - if (this.isDataUrl(url)) { - // Extract base64 data and MIME type from data URL - const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); - if (!base64Match) { - throw new Error( - `Invalid PDF data URL format: ${url.substring(0, 50)}...` - ); - } - - const extractedContentType = base64Match[1]; - const base64Data = base64Match[2]; - - // Verify the extracted type matches PDF - if (extractedContentType !== 'application/pdf') { - throw new Error( - `PDF contentType mismatch: expected application/pdf, got ${extractedContentType}` - ); - } - - return { - type: 'document', - source: { - type: 'base64', - media_type: 'application/pdf', - data: base64Data, - }, - }; - } else { - // File URL (HTTP/HTTPS/other) - contentType is already verified as 'application/pdf' - return { - type: 'document', - source: { - type: 'url', - url: url, - }, - }; - } - } - - // Handle non-PDF media (images) - existing logic - const { data, contentType } = - this.extractDataFromBase64Url(part.media.url) ?? {}; - if (!data || !contentType) { - throw new Error( - `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( - part.media - )}.` - ); - } - - // Resolve and validate the media type - const resolvedMediaType: string | undefined = - part.media.contentType ?? contentType; - if (!resolvedMediaType) { - throw new Error('Media type is required but was not provided'); - } - if (!isMediaType(resolvedMediaType)) { - throw new Error(`Unsupported media type: ${resolvedMediaType}`); - } - const mediaTypeValue: MediaType = resolvedMediaType; - - return { - type: 'image', - source: { - type: 'base64', - data, - media_type: mediaTypeValue, - }, - }; - } - if (part.toolRequest) { - if (!part.toolRequest.ref) { - throw new Error( - `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( - part.toolRequest - )}` - ); - } - return { - type: 'tool_use', - id: part.toolRequest.ref, - name: part.toolRequest.name, - input: part.toolRequest.input, - }; - } - if (part.toolResponse) { - if (!part.toolResponse.ref) { - throw new Error( - `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( - part.toolResponse - )}` - ); - } - return { - type: 'tool_result', - tool_use_id: part.toolResponse.ref, - content: [this.toAnthropicToolResponseContent(part)], - }; - } - throw new Error( - `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( - part - )}.` - ); - } - - protected toAnthropicMessages(messages: MessageData[]): { - system?: string; - messages: MessageParam[]; - } { - const system = - messages[0]?.role === 'system' - ? messages[0].content?.[0]?.text - : undefined; - const messagesToIterate = system ? messages.slice(1) : messages; - const anthropicMsgs: MessageParam[] = []; - for (const message of messagesToIterate) { - const msg = new GenkitMessage(message); - const content = msg.content.map((part) => - this.toAnthropicMessageContent(part) - ); - const toolMessageType = content.find( - (c) => c.type === 'tool_use' || c.type === 'tool_result' - ) as ToolUseBlockParam | ToolResultBlockParam; - const role = this.toAnthropicRole(message.role, toolMessageType?.type); - anthropicMsgs.push({ - role: role, - content, - }); - } - return { system, messages: anthropicMsgs }; - } - - protected toAnthropicTool(tool: ToolDefinition): Tool { - return { - name: tool.name, - description: tool.description, - input_schema: tool.inputSchema as Tool.InputSchema, - }; - } - - protected toAnthropicRequestBody( - modelName: string, - request: GenerateRequest, - stream?: boolean, - cacheSystemPrompt?: boolean - ): MessageCreateParams { - // Use supported model ref if available for version mapping, otherwise use modelName directly - const model = SUPPORTED_CLAUDE_MODELS[modelName]; - const { system, messages } = this.toAnthropicMessages(request.messages); - const mappedModelName = - request.config?.version ?? model?.version ?? modelName; - const body: MessageCreateParams = { - system: - cacheSystemPrompt && system - ? [ - { - type: 'text', - text: system, - cache_control: { type: 'ephemeral' }, - }, - ] - : system, - messages, - tools: request.tools?.map((tool) => this.toAnthropicTool(tool)), - max_tokens: - request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, - model: mappedModelName, - top_k: request.config?.topK, - top_p: request.config?.topP, - temperature: request.config?.temperature, - stop_sequences: request.config?.stopSequences, - metadata: request.config?.metadata, - tool_choice: request.config?.tool_choice, - stream, - }; - - if (request.output?.format && request.output.format !== 'text') { - throw new Error( - `Only text output format is supported for Claude models currently` - ); - } - // Remove undefined, null, and empty array values using a type-safe approach - const cleanedBody = Object.fromEntries( - Object.entries(body).filter(([_, value]) => { - if (value === undefined || value === null) return false; - if (Array.isArray(value) && value.length === 0) return false; - return true; - }) - ) as MessageCreateParams; - return cleanedBody; - } - - protected createMessage( - body: MessageCreateParams, - abortSignal: AbortSignal - ): Promise { - return this.client.messages.create(body, { - signal: abortSignal, - }) as Promise; - } - - protected streamMessages( - body: MessageCreateParams, - abortSignal: AbortSignal - ): MessageStreamLike { - return this.client.messages.stream(body, { - signal: abortSignal, - }) as MessageStream; - } - - protected toGenkitResponse(message: Message): GenerateResponseData { - return this.fromAnthropicResponse(message); - } - - protected toGenkitPart(event: MessageStreamEvent): Part | undefined { - return this.fromAnthropicContentBlockChunk(event); - } -} - -type BetaToolUseLike = - | BetaToolUseBlock - | BetaServerToolUseBlock - | BetaMCPToolUseBlock; - -export class BetaRunner extends Runner< - BetaMessage, - BetaRawMessageStreamEvent, - BetaMessageCreateParams -> { - constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { - super(name, client, cacheSystemPrompt); - } - - protected toAnthropicRole( - role: Role, - toolMessageType?: 'tool_use' | 'tool_result' - ): 'user' | 'assistant' { - if (role === 'user') { - return 'user'; - } - if (role === 'model') { - return 'assistant'; - } - if (role === 'tool') { - return toolMessageType === 'tool_use' ? 'assistant' : 'user'; - } - throw new Error(`Unsupported genkit role: ${role}`); - } - - protected toAnthropicToolResponseContent( - part: Part - ): - | BetaTextBlockParam - | BetaImageBlockParam - | BetaRequestDocumentBlock - | BetaSearchResultBlockParam { - const output = part.toolResponse?.output ?? {}; - - // Handle Media objects (images returned by tools) - if (this.isMediaObject(output)) { - const { data, contentType } = - this.extractDataFromBase64Url(output.url) ?? {}; - if (data && contentType && isMediaType(contentType)) { - return { - type: 'image', - source: { - type: 'base64', - data, - media_type: contentType, - }, - } as BetaImageBlockParam; - } - } - - // Handle string outputs - check if it's a data URL - if (typeof output === 'string') { - // Check if string is a data URL (e.g., "data:image/gif;base64,...") - if (this.isDataUrl(output)) { - const { data, contentType } = - this.extractDataFromBase64Url(output) ?? {}; - if (data && contentType && isMediaType(contentType)) { - return { - type: 'image', - source: { - type: 'base64', - data, - media_type: contentType, - }, - } as BetaImageBlockParam; - } - } - // Regular string output - return { - type: 'text', - text: output, - } as BetaTextBlockParam; - } - - // Handle other outputs by stringifying - return { - type: 'text', - text: JSON.stringify(output), - } as BetaTextBlockParam; - } - - protected toAnthropicMessageContent(part: Part): BetaContentBlockParam { - if (part.text) { - return { - type: 'text', - text: part.text, - }; - } - if (part.media) { - const resolvedContentType = part.media.contentType; - - // Check if this is a PDF document - if (resolvedContentType === 'application/pdf') { - const url = part.media.url; - - if (this.isDataUrl(url)) { - // Extract base64 data and MIME type from data URL - const base64Match = url.match(/^data:([^;]+);base64,(.+)$/); - if (!base64Match) { - throw new Error( - `Invalid PDF data URL format: ${url.substring(0, 50)}...` - ); - } - - const extractedContentType = base64Match[1]; - const base64Data = base64Match[2]; - - // Verify the extracted type matches PDF - if (extractedContentType !== 'application/pdf') { - throw new Error( - `PDF contentType mismatch: expected application/pdf, got ${extractedContentType}` - ); - } - - return { - type: 'document', - source: { - type: 'base64', - media_type: 'application/pdf', - data: base64Data, - }, - }; - } else { - // File URL (HTTP/HTTPS/other) - return { - type: 'document', - source: { - type: 'url', - url: url, - }, - }; - } - } - - // Handle non-PDF media (images) - const { data, contentType } = - this.extractDataFromBase64Url(part.media.url) ?? {}; - if (!data || !contentType) { - throw new Error( - `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( - part.media - )}.` - ); - } - - // Resolve and validate the media type - const resolvedMediaType: string | undefined = - part.media.contentType ?? contentType; - if (!resolvedMediaType) { - throw new Error('Media type is required but was not provided'); - } - if (!isMediaType(resolvedMediaType)) { - throw new Error(`Unsupported media type: ${resolvedMediaType}`); - } - const mediaTypeValue: MediaType = resolvedMediaType; - - return { - type: 'image', - source: { - type: 'base64', - data, - media_type: mediaTypeValue, - }, - }; - } - if (part.toolRequest) { - if (!part.toolRequest.ref) { - throw new Error( - `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( - part.toolRequest - )}` - ); - } - return { - type: 'tool_use', - id: part.toolRequest.ref, - name: part.toolRequest.name, - input: part.toolRequest.input, - }; - } - if (part.toolResponse) { - if (!part.toolResponse.ref) { - throw new Error( - `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( - part.toolResponse - )}` - ); - } - const betaResult: BetaToolResultBlockParam = { - type: 'tool_result', - tool_use_id: part.toolResponse.ref, - content: [this.toAnthropicToolResponseContent(part)], - }; - return betaResult; - } - throw new Error( - `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( - part - )}.` - ); - } - - protected toAnthropicMessages(messages: MessageData[]): { - system?: string; - messages: BetaMessageParam[]; - } { - const system = - messages[0]?.role === 'system' - ? messages[0].content?.[0]?.text - : undefined; - const messagesToIterate = system ? messages.slice(1) : messages; - const anthropicMsgs: BetaMessageParam[] = []; - for (const message of messagesToIterate) { - const msg = new GenkitMessage(message); - const content = msg.content.map((part) => - this.toAnthropicMessageContent(part) - ); - const toolMessageType = content.find( - (c) => c.type === 'tool_use' || c.type === 'tool_result' - ); - const role = this.toAnthropicRole( - message.role, - toolMessageType?.type as 'tool_use' | 'tool_result' | undefined - ); - anthropicMsgs.push({ - role: role, - content, - }); - } - return { system, messages: anthropicMsgs }; - } - - protected toAnthropicTool(tool: ToolDefinition): any { - return { - name: tool.name, - description: tool.description, - input_schema: tool.inputSchema as Tool.InputSchema, - }; - } - - protected createMessage( - body: BetaMessageCreateParams, - abortSignal: AbortSignal - ): Promise { - return this.client.beta.messages.create(body, { - signal: abortSignal, - }) as Promise; - } - - protected streamMessages( - body: BetaMessageCreateParams, - abortSignal: AbortSignal - ): MessageStreamLike { - return this.client.beta.messages.stream(body, { - signal: abortSignal, - }) as BetaMessageStream; - } - - protected toAnthropicRequestBody( - modelName: string, - request: GenerateRequest, - stream?: boolean, - cacheSystemPrompt?: boolean - ): BetaMessageCreateParams { - // Use supported model ref if available for version mapping, otherwise use modelName directly - const model = SUPPORTED_CLAUDE_MODELS[modelName]; - const { system, messages } = this.toAnthropicMessages(request.messages); - const mappedModelName = - request.config?.version ?? model?.version ?? modelName; - - // Convert system to beta format with cache control if needed - const betaSystem = - system === undefined - ? undefined - : cacheSystemPrompt - ? [ - { - type: 'text' as const, - text: system, - cache_control: { type: 'ephemeral' as const }, - }, - ] - : system; - - const body: BetaMessageCreateParams = { - model: mappedModelName, - max_tokens: - request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, - messages: messages, - }; - - if (betaSystem !== undefined) { - body.system = betaSystem; - } - if (stream !== undefined) { - body.stream = stream as false; - } - if (request.config?.stopSequences !== undefined) { - body.stop_sequences = request.config.stopSequences; - } - if (request.config?.temperature !== undefined) { - body.temperature = request.config.temperature; - } - if (request.config?.topK !== undefined) { - body.top_k = request.config.topK; - } - if (request.config?.topP !== undefined) { - body.top_p = request.config.topP; - } - if (request.config?.tool_choice !== undefined) { - body.tool_choice = request.config - .tool_choice as BetaMessageCreateParams['tool_choice']; - } - if (request.config?.metadata !== undefined) { - body.metadata = request.config - .metadata as BetaMessageCreateParams['metadata']; - } - if (request.tools) { - body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); - } - - if (request.output?.format && request.output.format !== 'text') { - throw new Error( - `Only text output format is supported for Claude models currently` - ); - } - - return body; - } - - protected toGenkitResponse(message: BetaMessage): GenerateResponseData { - return { - candidates: [ - { - index: 0, - finishReason: this.fromBetaStopReason(message.stop_reason), - message: { - role: 'model', - content: message.content.map((block) => - this.fromBetaContentBlock(block) - ), - }, - }, - ], - usage: { - inputTokens: message.usage.input_tokens, - outputTokens: message.usage.output_tokens, - }, - custom: message, - }; - } - - protected toGenkitPart(event: BetaRawMessageStreamEvent): Part | undefined { - if (event.type === 'content_block_start') { - 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 { text: event.delta.thinking }; - } - return undefined; - } - return undefined; - } - - private fromBetaContentBlock(contentBlock: BetaContentBlock): Part { - switch (contentBlock.type) { - case 'tool_use': - case 'server_tool_use': - case 'mcp_tool_use': - return { - toolRequest: { - ref: contentBlock.id, - name: this.betaToolName(contentBlock), - input: contentBlock.input, - }, - }; - case 'text': - return { text: contentBlock.text }; - case 'thinking': - return { text: contentBlock.thinking }; - case 'redacted_thinking': - return { text: contentBlock.data }; - default: { - const unknownType = (contentBlock as { type: string }).type; - console.warn( - `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` - ); - return { text: '' }; - } - } - } - - private betaToolName(block: BetaToolUseLike): string { - if ('server_name' in block && block.server_name) { - return `${block.server_name}/${block.name}`; - } - return block.name ?? 'unknown_tool'; - } - - private fromBetaStopReason( - reason: BetaStopReason | null - ): ModelResponseData['finishReason'] { - switch (reason) { - case 'max_tokens': - case 'model_context_window_exceeded': - return 'length'; - case 'end_turn': - case 'stop_sequence': - case 'tool_use': - case 'pause_turn': - return 'stop'; - case null: - return 'unknown'; - case 'refusal': - return 'other'; - default: - return 'other'; - } - } -} diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts new file mode 100644 index 0000000000..ad47daa680 --- /dev/null +++ b/js/plugins/anthropic/src/runner/base.ts @@ -0,0 +1,486 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Anthropic } from '@anthropic-ai/sdk'; +import type { + ContentBlock, + Message, + MessageStreamEvent, +} from '@anthropic-ai/sdk/resources/messages'; +import type { + GenerateRequest, + GenerateResponseChunkData, + GenerateResponseData, + MessageData, + ModelResponseData, + Part, + Role, +} from 'genkit'; +import { Message as GenkitMessage } from 'genkit'; +import type { ToolDefinition } from 'genkit/model'; + +import { + AnthropicConfigSchema, + Media, + MediaSchema, + MediaType, + MediaTypeSchema, +} from '../types.js'; + +/** + * Type constraint for runner type parameters. + */ +export type RunnerTypes = { + Message: unknown; + Stream: AsyncIterable & { finalMessage(): Promise }; + StreamEvent: unknown; + RequestBody: unknown; + Tool: unknown; + MessageParam: unknown; + ToolResponseContent: unknown; +}; + +type RunnerMessage = T['Message']; +type RunnerStream = T['Stream']; +type RunnerStreamEvent = T['StreamEvent']; +type RunnerRequestBody = T['RequestBody']; +type RunnerTool = T['Tool']; +type RunnerMessageParam = T['MessageParam']; +type RunnerToolResponseContent = + T['ToolResponseContent']; + +export abstract class BaseRunner { + protected name: string; + protected client: Anthropic; + protected cacheSystemPrompt?: boolean; + + /** + * Default maximum output tokens for Claude models when not specified in the request. + */ + protected readonly DEFAULT_MAX_OUTPUT_TOKENS = 4096; + + constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { + this.name = name; + this.client = client; + this.cacheSystemPrompt = cacheSystemPrompt; + } + + /** + * Converts a Genkit role to the corresponding Anthropic role. + */ + protected toAnthropicRole( + role: Role, + toolMessageType?: 'tool_use' | 'tool_result' + ): 'user' | 'assistant' { + if (role === 'user') { + return 'user'; + } + if (role === 'model') { + return 'assistant'; + } + if (role === 'tool') { + return toolMessageType === 'tool_use' ? 'assistant' : 'user'; + } + throw new Error(`Unsupported genkit role: ${role}`); + } + + protected isMediaType(value: string): value is MediaType { + return MediaTypeSchema.safeParse(value).success; + } + + protected isMediaObject(obj: unknown): obj is Media { + return MediaSchema.safeParse(obj).success; + } + + /** + * Checks if a URL is a data URL (starts with 'data:'). + * This follows the Google GenAI plugin pattern for distinguishing inline data from file references. + */ + protected isDataUrl(url: string): boolean { + return url.startsWith('data:'); + } + + protected extractDataFromBase64Url( + url: string + ): { data: string; contentType: string } | null { + const match = url.match(/^data:([^;]+);base64,(.+)$/); + return ( + match && { + contentType: match[1], + data: match[2], + } + ); + } + + protected toPdfDocumentSource(media: Media): + | { + type: 'base64'; + media_type: 'application/pdf'; + data: string; + } + | { + type: 'url'; + url: string; + } { + if (media.contentType !== 'application/pdf') { + throw new Error( + `PDF contentType mismatch: expected application/pdf, got ${media.contentType}` + ); + } + const url = media.url; + if (this.isDataUrl(url)) { + const extracted = this.extractDataFromBase64Url(url); + if (!extracted) { + throw new Error( + `Invalid PDF data URL format: ${url.substring(0, 50)}...` + ); + } + const { data, contentType } = extracted; + if (contentType !== 'application/pdf') { + throw new Error( + `PDF contentType mismatch: expected application/pdf, got ${contentType}` + ); + } + return { + type: 'base64', + media_type: 'application/pdf', + data, + }; + } + return { + type: 'url', + url, + }; + } + + protected toImageSource(media: Media): { + data: string; + mediaType: MediaType; + } { + const extracted = this.extractDataFromBase64Url(media.url); + const { data, contentType } = extracted ?? {}; + if (!data || !contentType) { + throw new Error( + `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( + media + )}.` + ); + } + const resolvedMediaType = media.contentType ?? contentType; + if (!resolvedMediaType) { + throw new Error('Media type is required but was not provided'); + } + if (!this.isMediaType(resolvedMediaType)) { + throw new Error(`Unsupported media type: ${resolvedMediaType}`); + } + return { + data, + mediaType: resolvedMediaType, + }; + } + + /** + * Converts tool response output to the appropriate Anthropic content format. + * Handles Media objects, data URLs, strings, and other outputs. + */ + protected toAnthropicToolResponseContent( + part: Part + ): RunnerToolResponseContent { + const output = part.toolResponse?.output ?? {}; + + // Handle Media objects (images returned by tools) + if (this.isMediaObject(output)) { + const { data, contentType } = + this.extractDataFromBase64Url(output.url) ?? {}; + if (data && contentType && this.isMediaType(contentType)) { + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: contentType, + }, + } as RunnerToolResponseContent; + } + } + + // Handle string outputs - check if it's a data URL + if (typeof output === 'string') { + // Check if string is a data URL (e.g., "data:image/gif;base64,...") + if (this.isDataUrl(output)) { + const { data, contentType } = + this.extractDataFromBase64Url(output) ?? {}; + if (data && contentType && this.isMediaType(contentType)) { + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: contentType, + }, + } as RunnerToolResponseContent; + } + } + // Regular string output + return { + type: 'text', + text: output, + } as RunnerToolResponseContent; + } + + // Handle other outputs by stringifying + return { + type: 'text', + text: JSON.stringify(output), + } as RunnerToolResponseContent; + } + + /** + * Converts a Genkit Part to the corresponding Anthropic content block. + * Each runner implements this to return its specific API type. + */ + protected abstract toAnthropicMessageContent(part: Part): any; + + /** + * Converts Genkit messages to Anthropic format. + * Extracts system message and converts remaining messages using the runner's + * toAnthropicMessageContent implementation. + */ + protected toAnthropicMessages(messages: MessageData[]): { + system?: string; + messages: RunnerMessageParam[]; + } { + const system = + messages[0]?.role === 'system' + ? messages[0].content?.[0]?.text + : undefined; + const messagesToIterate = system ? messages.slice(1) : messages; + const anthropicMsgs: RunnerMessageParam[] = []; + for (const message of messagesToIterate) { + const msg = new GenkitMessage(message); + const content = msg.content.map((part) => + this.toAnthropicMessageContent(part) + ); + const toolMessageType = content.find( + (c: any) => c.type === 'tool_use' || c.type === 'tool_result' + ); + const role = this.toAnthropicRole( + message.role, + toolMessageType?.type as 'tool_use' | 'tool_result' | undefined + ); + anthropicMsgs.push({ + role: role, + content, + } as RunnerMessageParam); + } + return { system, messages: anthropicMsgs }; + } + + /** + * Converts a Genkit ToolDefinition to an Anthropic Tool object. + */ + protected toAnthropicTool(tool: ToolDefinition): RunnerTool { + return { + name: tool.name, + description: tool.description, + input_schema: tool.inputSchema, + } as RunnerTool; + } + + /** + * Converts an Anthropic content block to a Genkit Part object. + * @param contentBlock The Anthropic content block to convert. + * @returns The converted Genkit Part object. + * @param event The Anthropic message stream event to convert. + * @returns The converted Genkit Part object if the event is a content block + * start or delta, otherwise undefined. + */ + protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { + if (contentBlock.type === 'tool_use') { + return { + toolRequest: { + ref: contentBlock.id, + name: contentBlock.name, + input: contentBlock.input, + }, + }; + } else if (contentBlock.type === 'text') { + return { text: contentBlock.text }; + } else if (contentBlock.type === 'thinking') { + return { text: contentBlock.thinking }; + } else if (contentBlock.type === 'redacted_thinking') { + return { text: contentBlock.data }; + } else { + // Handle unexpected content block types + // Log warning for debugging, but return empty text to avoid breaking the flow + const unknownType = (contentBlock as { type: string }).type; + console.warn( + `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` + ); + return { text: '' }; + } + } + + /** + * Converts an Anthropic message stream event to a Genkit Part object. + */ + protected fromAnthropicContentBlockChunk( + event: MessageStreamEvent + ): Part | undefined { + if ( + event.type !== 'content_block_start' && + event.type !== 'content_block_delta' + ) { + return; + } + const eventField = + event.type === 'content_block_start' ? 'content_block' : 'delta'; + return ['text', 'text_delta'].includes(event[eventField].type) + ? { + text: event[eventField].text, + } + : { + toolRequest: { + ref: event[eventField].id, + name: event[eventField].name, + input: event[eventField].input, + }, + }; + } + + protected fromAnthropicStopReason( + reason: Message['stop_reason'] + ): ModelResponseData['finishReason'] { + switch (reason) { + case 'max_tokens': + return 'length'; + case 'end_turn': + // fall through + case 'stop_sequence': + // fall through + case 'tool_use': + return 'stop'; + case null: + return 'unknown'; + default: + 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, + }; + } + + /** + * Converts an Anthropic request to an Anthropic API request body. + * @param modelName The name of the Anthropic model to use. + * @param request The Genkit GenerateRequest to convert. + * @param stream Whether to stream the response. + * @param cacheSystemPrompt Whether to cache the system prompt. + * @returns The converted Anthropic API request body. + * @throws An error if an unsupported output format is requested. + */ + protected abstract toAnthropicRequestBody( + modelName: string, + request: GenerateRequest, + stream?: boolean, + cacheSystemPrompt?: boolean + ): RunnerRequestBody; + + protected abstract createMessage( + body: RunnerRequestBody, + abortSignal: AbortSignal + ): + | Promise | RunnerStream> + | PromiseLike | RunnerStream>; + + protected abstract streamMessages( + body: RunnerRequestBody, + abortSignal: AbortSignal + ): RunnerStream; + + protected abstract toGenkitResponse( + message: RunnerMessage + ): GenerateResponseData; + + protected abstract toGenkitPart( + event: RunnerStreamEvent + ): Part | undefined; + + public async run( + request: GenerateRequest, + options: { + streamingRequested: boolean; + sendChunk: (chunk: GenerateResponseChunkData) => void; + abortSignal: AbortSignal; + } + ): Promise { + const { streamingRequested, sendChunk, abortSignal } = options; + + const body = this.toAnthropicRequestBody( + this.name, + request, + streamingRequested, + this.cacheSystemPrompt + ); + + if (streamingRequested) { + const stream = this.streamMessages(body, abortSignal); + for await (const event of stream) { + const part = this.toGenkitPart(event); + if (part) { + sendChunk({ + index: 0, + content: [part], + }); + } + } + const finalMessage = await stream.finalMessage(); + return this.toGenkitResponse(finalMessage); + } + + const response = await this.createMessage(body, abortSignal); + // Type narrowing: ensure we got a message, not a stream + if ( + typeof response === 'object' && + response !== null && + 'finalMessage' in response + ) { + throw new Error( + 'Unexpected stream returned from non-streaming createMessage request' + ); + } + return this.toGenkitResponse(response as RunnerMessage); + } +} diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts new file mode 100644 index 0000000000..d7e745c050 --- /dev/null +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -0,0 +1,315 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Anthropic } from '@anthropic-ai/sdk'; +import type { + BetaContentBlock, + BetaContentBlockParam, + BetaImageBlockParam, + BetaMCPToolUseBlock, + BetaMessage, + MessageCreateParams as BetaMessageCreateParams, + BetaMessageParam, + BetaRawMessageStreamEvent, + BetaServerToolUseBlock, + BetaStopReason, + BetaTextBlockParam, + BetaToolResultBlockParam, + BetaToolUseBlock, +} from '@anthropic-ai/sdk/resources/beta/messages'; +import type { + GenerateRequest, + GenerateResponseData, + ModelResponseData, + Part, +} from 'genkit'; + +import { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream.js'; +import type { BetaTool } from '@anthropic-ai/sdk/resources/beta/messages'; +import { SUPPORTED_CLAUDE_MODELS } from '../models.js'; +import { AnthropicConfigSchema } from '../types.js'; +import { BaseRunner } from './base.js'; + +type BetaToolUseLike = + | BetaToolUseBlock + | BetaServerToolUseBlock + | BetaMCPToolUseBlock; + +type BetaRunnerTypes = { + Message: BetaMessage; + Stream: BetaMessageStream; + StreamEvent: BetaRawMessageStreamEvent; + RequestBody: BetaMessageCreateParams; + Tool: BetaTool; + MessageParam: BetaMessageParam; + ToolResponseContent: BetaTextBlockParam | BetaImageBlockParam; +}; + +/** + * Runner for the Anthropic Beta API. + */ +export class BetaRunner extends BaseRunner { + constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { + super(name, client, cacheSystemPrompt); + } + + protected toAnthropicMessageContent(part: Part): BetaContentBlockParam { + if (part.text) { + return { + type: 'text', + text: part.text, + }; + } + if (part.media) { + if (part.media.contentType === 'application/pdf') { + return { + type: 'document', + source: this.toPdfDocumentSource(part.media), + }; + } + + const { data, mediaType } = this.toImageSource(part.media); + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: mediaType, + }, + }; + } + if (part.toolRequest) { + if (!part.toolRequest.ref) { + throw new Error( + `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolRequest + )}` + ); + } + return { + type: 'tool_use', + id: part.toolRequest.ref, + name: part.toolRequest.name, + input: part.toolRequest.input, + }; + } + if (part.toolResponse) { + if (!part.toolResponse.ref) { + throw new Error( + `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolResponse + )}` + ); + } + const betaResult: BetaToolResultBlockParam = { + type: 'tool_result', + tool_use_id: part.toolResponse.ref, + content: [this.toAnthropicToolResponseContent(part)], + }; + return betaResult; + } + throw new Error( + `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( + part + )}.` + ); + } + + protected createMessage( + body: BetaMessageCreateParams, + abortSignal: AbortSignal + ): Promise { + // TODO: try to avoid cast + return this.client.beta.messages.create(body, { + signal: abortSignal, + }) as Promise; + } + + protected streamMessages( + body: BetaMessageCreateParams, + abortSignal: AbortSignal + ): BetaMessageStream { + return this.client.beta.messages.stream(body, { + signal: abortSignal, + }); + } + + protected toAnthropicRequestBody( + modelName: string, + request: GenerateRequest, + stream?: boolean, + cacheSystemPrompt?: boolean + ): BetaMessageCreateParams { + // Use supported model ref if available for version mapping, otherwise use modelName directly + const model = SUPPORTED_CLAUDE_MODELS[modelName]; + const { system, messages } = this.toAnthropicMessages(request.messages); + const mappedModelName = + request.config?.version ?? model?.version ?? modelName; + + // Convert system to beta format with cache control if needed + const betaSystem = + system === undefined + ? undefined + : cacheSystemPrompt + ? [ + { + type: 'text' as const, + text: system, + cache_control: { type: 'ephemeral' as const }, + }, + ] + : system; + + const body: BetaMessageCreateParams = { + model: mappedModelName, + max_tokens: + request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, + messages: messages, + }; + + if (betaSystem !== undefined) { + body.system = betaSystem; + } + if (stream !== undefined) { + body.stream = stream as false; + } + if (request.config?.stopSequences !== undefined) { + body.stop_sequences = request.config.stopSequences; + } + if (request.config?.temperature !== undefined) { + body.temperature = request.config.temperature; + } + if (request.config?.topK !== undefined) { + body.top_k = request.config.topK; + } + if (request.config?.topP !== undefined) { + body.top_p = request.config.topP; + } + if (request.config?.tool_choice !== undefined) { + body.tool_choice = request.config + .tool_choice as BetaMessageCreateParams['tool_choice']; + } + if (request.config?.metadata !== undefined) { + body.metadata = request.config + .metadata as BetaMessageCreateParams['metadata']; + } + if (request.tools) { + body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); + } + + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + + return body; + } + + protected toGenkitResponse(message: BetaMessage): GenerateResponseData { + return { + candidates: [ + { + index: 0, + finishReason: this.fromBetaStopReason(message.stop_reason), + message: { + role: 'model', + content: message.content.map((block) => + this.fromBetaContentBlock(block) + ), + }, + }, + ], + usage: { + inputTokens: message.usage.input_tokens, + outputTokens: message.usage.output_tokens, + }, + custom: message, + }; + } + + protected toGenkitPart(event: BetaRawMessageStreamEvent): Part | undefined { + if (event.type === 'content_block_start') { + 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 { text: event.delta.thinking }; + } + return undefined; + } + return undefined; + } + + private fromBetaContentBlock(contentBlock: BetaContentBlock): Part { + switch (contentBlock.type) { + case 'tool_use': + case 'server_tool_use': + case 'mcp_tool_use': + return { + toolRequest: { + ref: contentBlock.id, + name: this.betaToolName(contentBlock), + input: contentBlock.input, + }, + }; + case 'text': + return { text: contentBlock.text }; + case 'thinking': + return { text: contentBlock.thinking }; + case 'redacted_thinking': + return { text: contentBlock.data }; + default: { + const unknownType = (contentBlock as { type: string }).type; + console.warn( + `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` + ); + return { text: '' }; + } + } + } + + private betaToolName(block: BetaToolUseLike): string { + if ('server_name' in block && block.server_name) { + return `${block.server_name}/${block.name}`; + } + return block.name ?? 'unknown_tool'; + } + + private fromBetaStopReason( + reason: BetaStopReason | null + ): ModelResponseData['finishReason'] { + switch (reason) { + case 'max_tokens': + case 'model_context_window_exceeded': + return 'length'; + case 'end_turn': + case 'stop_sequence': + case 'tool_use': + case 'pause_turn': + return 'stop'; + case null: + return 'unknown'; + case 'refusal': + return 'other'; + default: + return 'other'; + } + } +} diff --git a/js/plugins/anthropic/src/runner/index.ts b/js/plugins/anthropic/src/runner/index.ts new file mode 100644 index 0000000000..0872cee3fc --- /dev/null +++ b/js/plugins/anthropic/src/runner/index.ts @@ -0,0 +1,19 @@ +/** + * 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 { BaseRunner } from './base.js'; +export { BetaRunner } from './beta.js'; +export { Runner } from './runner.js'; diff --git a/js/plugins/anthropic/src/runner/runner.ts b/js/plugins/anthropic/src/runner/runner.ts new file mode 100644 index 0000000000..5068f87f62 --- /dev/null +++ b/js/plugins/anthropic/src/runner/runner.ts @@ -0,0 +1,213 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Anthropic } from '@anthropic-ai/sdk'; +import { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream.js'; +import type { + DocumentBlockParam, + ImageBlockParam, + Message, + MessageCreateParams, + MessageParam, + MessageStreamEvent, + TextBlockParam, + Tool, + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/messages'; +import type { GenerateRequest, GenerateResponseData, Part } from 'genkit'; + +import { SUPPORTED_CLAUDE_MODELS } from '../models.js'; +import { AnthropicConfigSchema } from '../types.js'; +import { BaseRunner } from './base.js'; + +type RunnerTypes = { + Message: Message; + Stream: MessageStream; + StreamEvent: MessageStreamEvent; + RequestBody: MessageCreateParams; + Tool: Tool; + MessageParam: MessageParam; + ToolResponseContent: TextBlockParam | ImageBlockParam; +}; + +export class Runner extends BaseRunner { + constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { + super(name, client, cacheSystemPrompt); + } + + protected toAnthropicMessageContent( + part: Part + ): + | TextBlockParam + | ImageBlockParam + | DocumentBlockParam + | ToolUseBlockParam + | ToolResultBlockParam { + if (part.text) { + return { + type: 'text', + text: part.text, + citations: null, + }; + } + if (part.media) { + if (part.media.contentType === 'application/pdf') { + return { + type: 'document', + source: this.toPdfDocumentSource(part.media), + }; + } + + const { data, mediaType } = this.toImageSource(part.media); + return { + type: 'image', + source: { + type: 'base64', + data, + media_type: mediaType, + }, + }; + } + if (part.toolRequest) { + if (!part.toolRequest.ref) { + throw new Error( + `Tool request ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolRequest + )}` + ); + } + return { + type: 'tool_use', + id: part.toolRequest.ref, + name: part.toolRequest.name, + input: part.toolRequest.input, + }; + } + if (part.toolResponse) { + if (!part.toolResponse.ref) { + throw new Error( + `Tool response ref is required for Anthropic API. Part: ${JSON.stringify( + part.toolResponse + )}` + ); + } + return { + type: 'tool_result', + tool_use_id: part.toolResponse.ref, + content: [this.toAnthropicToolResponseContent(part)], + }; + } + throw new Error( + `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( + part + )}.` + ); + } + + protected toAnthropicRequestBody( + modelName: string, + request: GenerateRequest, + stream?: boolean, + cacheSystemPrompt?: boolean + ): MessageCreateParams { + // Use supported model ref if available for version mapping, otherwise use modelName directly + const model = SUPPORTED_CLAUDE_MODELS[modelName]; + const { system, messages } = this.toAnthropicMessages(request.messages); + const mappedModelName = + request.config?.version ?? model?.version ?? modelName; + const systemValue = + system === undefined + ? undefined + : cacheSystemPrompt + ? [ + { + type: 'text' as const, + text: system, + cache_control: { type: 'ephemeral' as const }, + }, + ] + : system; + const body: MessageCreateParams = { + model: mappedModelName, + max_tokens: + request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, + messages, + }; + + if (systemValue !== undefined) { + body.system = systemValue; + } + + if (stream !== undefined) { + body.stream = stream as false; + } + if (request.tools) { + body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); + } + if (request.config?.topK !== undefined) { + body.top_k = request.config.topK; + } + if (request.config?.topP !== undefined) { + body.top_p = request.config.topP; + } + if (request.config?.temperature !== undefined) { + body.temperature = request.config.temperature; + } + if (request.config?.stopSequences !== undefined) { + body.stop_sequences = request.config.stopSequences; + } + if (request.config?.metadata !== undefined) { + body.metadata = request.config.metadata; + } + if (request.config?.tool_choice !== undefined) { + body.tool_choice = request.config.tool_choice; + } + + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + return body; + } + + protected createMessage( + body: MessageCreateParams, + abortSignal: AbortSignal + ): Promise { + return this.client.messages.create(body, { + signal: abortSignal, + }) as Promise; + } + + protected streamMessages( + body: MessageCreateParams, + abortSignal: AbortSignal + ): MessageStream { + 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); + } +} diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index de86217dfe..8260f2bcb5 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -78,9 +78,23 @@ export interface Media { contentType?: string; } -export enum MediaType { - JPEG = 'image/jpeg', - PNG = 'image/png', - GIF = 'image/gif', - WEBP = 'image/webp', -} +export const MediaSchema = z.object({ + url: z.string(), + contentType: z.string().optional(), +}); + +export const MediaTypeSchema = z.enum([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', +]); + +export type MediaType = z.infer; + +export const MEDIA_TYPES = { + JPEG: 'image/jpeg', + PNG: 'image/png', + GIF: 'image/gif', + WEBP: 'image/webp', +} as const satisfies Record; diff --git a/js/plugins/anthropic/tests/converters_test.ts b/js/plugins/anthropic/tests/converters_test.ts index 94de449fdc..5dffe93015 100644 --- a/js/plugins/anthropic/tests/converters_test.ts +++ b/js/plugins/anthropic/tests/converters_test.ts @@ -33,7 +33,7 @@ import type { CandidateData, ToolDefinition } from 'genkit/model'; import { describe, it, mock } from 'node:test'; import { claudeModel, claudeRunner } from '../src/models.js'; -import { RegularRunner } from '../src/runner.js'; +import { Runner } from '../src/runner/runner.js'; import type { AnthropicConfigSchema } from '../src/types.js'; import { createMockAnthropicClient, @@ -42,9 +42,22 @@ import { mockToolUseChunk, } from './mocks/anthropic-client.js'; -// Test helper: Create a RegularRunner instance for testing converter methods +// Test helper: Create a Runner instance for testing converter methods const mockClient = createMockAnthropicClient(); -const testRunner = new RegularRunner('test-model', mockClient); +const testRunner = new Runner('test-model', mockClient); + +const createUsage = ( + overrides: Partial = {} +): Message['usage'] => ({ + cache_creation: null, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + input_tokens: 0, + output_tokens: 0, + server_tool_use: null, + service_tier: null, + ...overrides, +}); // Helper functions to access protected methods for testing const toAnthropicRole = ( @@ -774,12 +787,12 @@ describe('fromAnthropicResponse', () => { citations: null, }, ], - usage: { + usage: createUsage({ input_tokens: 10, output_tokens: 20, cache_creation_input_tokens: null, cache_read_input_tokens: null, - }, + }), }, expectedOutput: { candidates: [ @@ -815,12 +828,12 @@ describe('fromAnthropicResponse', () => { input: { topic: 'dogs' }, }, ], - usage: { + usage: createUsage({ input_tokens: 10, output_tokens: 20, cache_creation_input_tokens: null, cache_read_input_tokens: null, - }, + }), }, expectedOutput: { candidates: [ @@ -1018,12 +1031,12 @@ describe('claudeRunner', () => { const mockClient = createMockAnthropicClient({ messageResponse: { content: [{ type: 'text', text: 'response', citations: null }], - usage: { + usage: createUsage({ input_tokens: 10, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, - }, + }), }, }); @@ -1062,12 +1075,12 @@ describe('claudeRunner', () => { ], messageResponse: { content: [{ type: 'text', text: 'response', citations: null }], - usage: { + usage: createUsage({ input_tokens: 10, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, - }, + }), }, }); @@ -1097,12 +1110,12 @@ describe('claudeRunner', () => { const mockClient = createMockAnthropicClient({ messageResponse: { content: [{ type: 'text', text: 'response', citations: null }], - usage: { + usage: createUsage({ input_tokens: 10, output_tokens: 20, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, - }, + }), }, }); @@ -1151,12 +1164,12 @@ describe('claudeModel', () => { ], messageResponse: { content: [{ type: 'text', text: 'Hello world!', citations: null }], - usage: { + usage: createUsage({ input_tokens: 5, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, - }, + }), }, }); @@ -1203,12 +1216,12 @@ describe('claudeModel', () => { }, ], stop_reason: 'tool_use', - usage: { + usage: createUsage({ input_tokens: 15, output_tokens: 25, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, - }, + }), }, }); @@ -1263,12 +1276,12 @@ describe('claudeModel', () => { streamError: streamError, messageResponse: { content: [{ type: 'text', text: 'Hello world', citations: null }], - usage: { + usage: createUsage({ input_tokens: 5, output_tokens: 10, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, - }, + }), }, }); @@ -1313,12 +1326,12 @@ describe('claudeModel', () => { ], messageResponse: { content: [{ type: 'text', text: 'Hello world!', citations: null }], - usage: { + usage: createUsage({ input_tokens: 5, output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, - }, + }), }, }); diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index ec5c811295..f9710f2b3f 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -132,6 +132,9 @@ describe('Anthropic Integration', () => { output_tokens: 15, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, + cache_creation: null, + server_tool_use: null, + service_tier: null, }, }, }); @@ -240,6 +243,9 @@ describe('Anthropic Integration', () => { output_tokens: 50, cache_creation_input_tokens: 5, cache_read_input_tokens: 10, + cache_creation: null, + server_tool_use: null, + service_tier: null, }, }, }); diff --git a/js/plugins/anthropic/tests/mocks/anthropic-client.ts b/js/plugins/anthropic/tests/mocks/anthropic-client.ts index fd7e1bc4cb..39d2cb70d8 100644 --- a/js/plugins/anthropic/tests/mocks/anthropic-client.ts +++ b/js/plugins/anthropic/tests/mocks/anthropic-client.ts @@ -205,12 +205,7 @@ export interface CreateMockAnthropicMessageOptions { input: any; }; stopReason?: Message['stop_reason']; - usage?: { - input_tokens?: number; - output_tokens?: number; - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - }; + usage?: Partial; } /** @@ -248,6 +243,17 @@ export function createMockAnthropicMessage( }); } + const usage: Message['usage'] = { + cache_creation: null, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + input_tokens: 10, + output_tokens: 20, + server_tool_use: null, + service_tier: null, + ...(options.usage ?? {}), + }; + return { id: options.id || 'msg_test123', type: 'message', @@ -257,13 +263,7 @@ export function createMockAnthropicMessage( stop_reason: options.stopReason || (options.toolUse ? 'tool_use' : 'end_turn'), stop_sequence: null, - usage: { - input_tokens: options.usage?.input_tokens ?? 10, - output_tokens: options.usage?.output_tokens ?? 20, - cache_creation_input_tokens: - options.usage?.cache_creation_input_tokens ?? 0, - cache_read_input_tokens: options.usage?.cache_read_input_tokens ?? 0, - }, + usage, }; } @@ -371,13 +371,13 @@ function toBetaMessage(message: Message): BetaMessage { container: null, context_management: null, usage: { - cache_creation: null, + cache_creation: message.usage.cache_creation, cache_creation_input_tokens: message.usage.cache_creation_input_tokens, cache_read_input_tokens: message.usage.cache_read_input_tokens, input_tokens: message.usage.input_tokens, output_tokens: message.usage.output_tokens, - server_tool_use: null, - service_tier: null, + server_tool_use: message.usage.server_tool_use, + service_tier: message.usage.service_tier, }, }; } From dd9c8b83313caf9aad56cf6eb65f30b811cceb2b Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 10 Nov 2025 16:09:20 +0000 Subject: [PATCH 17/51] refactor(anthropic): tighten up API and tests --- js/plugins/anthropic/src/index.ts | 6 +- js/plugins/anthropic/src/models.ts | 8 +- js/plugins/anthropic/src/runner/base.ts | 78 ++++---- js/plugins/anthropic/src/runner/beta.ts | 105 ++++++++-- js/plugins/anthropic/src/runner/runner.ts | 92 +++++++-- js/plugins/anthropic/tests/index_test.ts | 4 +- .../anthropic/tests/mocks/anthropic-client.ts | 6 +- .../{converters_test.ts => runner_test.ts} | 188 ++++++++++++------ 8 files changed, 346 insertions(+), 141 deletions(-) rename js/plugins/anthropic/tests/{converters_test.ts => runner_test.ts} (88%) diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index ee672b5e7e..03812f2c11 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -28,8 +28,8 @@ import { AnthropicConfigSchemaType, ClaudeConfig, ClaudeModelName, + KNOWN_CLAUDE_MODELS, KnownClaudeModels, - SUPPORTED_CLAUDE_MODELS, claude35Haiku, claude3Haiku, claude41Opus, @@ -60,7 +60,7 @@ async function list(client: Anthropic): Promise { // Remove the date suffix from the model id const normalizedId = modelInfo.id.replace(/-\d{8}$/, ''); // Get the model reference from the supported models - const ref = SUPPORTED_CLAUDE_MODELS[normalizedId]; + const ref = KNOWN_CLAUDE_MODELS[normalizedId]; // Add the model action metadata if the model is supported if (ref) { result.push( @@ -137,7 +137,7 @@ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { name: 'anthropic', init: async () => { const actions: ModelAction[] = []; - for (const name of Object.keys(SUPPORTED_CLAUDE_MODELS)) { + for (const name of Object.keys(KNOWN_CLAUDE_MODELS)) { const action = claudeModel(name, client, options?.cacheSystemPrompt); actions.push(action); } diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 8dcdafbb15..3f02434ffa 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -155,7 +155,7 @@ export const claude41Opus = modelRef({ version: 'claude-opus-4-1-latest', }); -export const SUPPORTED_CLAUDE_MODELS: Record< +export const KNOWN_CLAUDE_MODELS: Record< string, ModelReference > = { @@ -168,7 +168,7 @@ export const SUPPORTED_CLAUDE_MODELS: Record< 'claude-4-1-opus': claude41Opus, }; -export type KnownClaudeModels = keyof typeof SUPPORTED_CLAUDE_MODELS; +export type KnownClaudeModels = keyof typeof KNOWN_CLAUDE_MODELS; export type ClaudeModelName = string; export type AnthropicConfigSchemaType = typeof AnthropicConfigSchema; export type ClaudeConfig = z.infer; @@ -229,7 +229,7 @@ export function claudeModelReference( name: string, config?: z.infer ): ModelReference { - const knownModel = SUPPORTED_CLAUDE_MODELS[name]; + const knownModel = KNOWN_CLAUDE_MODELS[name]; if (knownModel) { return modelRef({ name: knownModel.name, @@ -260,7 +260,7 @@ export function claudeModel( cacheSystemPrompt?: boolean ): ModelAction { // Use supported model ref if available, otherwise create generic model ref - const modelRef = SUPPORTED_CLAUDE_MODELS[name]; + const modelRef = KNOWN_CLAUDE_MODELS[name]; const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO; return model( diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index ad47daa680..629fda4410 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -17,6 +17,7 @@ import { Anthropic } from '@anthropic-ai/sdk'; import type { ContentBlock, + DocumentBlockParam, Message, MessageStreamEvent, } from '@anthropic-ai/sdk/resources/messages'; @@ -48,6 +49,7 @@ export type RunnerTypes = { Stream: AsyncIterable & { finalMessage(): Promise }; StreamEvent: unknown; RequestBody: unknown; + StreamingRequestBody: unknown; Tool: unknown; MessageParam: unknown; ToolResponseContent: unknown; @@ -57,6 +59,8 @@ type RunnerMessage = T['Message']; type RunnerStream = T['Stream']; type RunnerStreamEvent = T['StreamEvent']; type RunnerRequestBody = T['RequestBody']; +type RunnerStreamingRequestBody = + T['StreamingRequestBody']; type RunnerTool = T['Tool']; type RunnerMessageParam = T['MessageParam']; type RunnerToolResponseContent = @@ -125,16 +129,7 @@ export abstract class BaseRunner { ); } - protected toPdfDocumentSource(media: Media): - | { - type: 'base64'; - media_type: 'application/pdf'; - data: string; - } - | { - type: 'url'; - url: string; - } { + protected toPdfDocumentSource(media: Media): DocumentBlockParam['source'] { if (media.contentType !== 'application/pdf') { throw new Error( `PDF contentType mismatch: expected application/pdf, got ${media.contentType}` @@ -340,6 +335,14 @@ export abstract class BaseRunner { protected fromAnthropicContentBlockChunk( event: MessageStreamEvent ): Part | undefined { + if ( + event.type === 'content_block_delta' && + event.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 ( event.type !== 'content_block_start' && event.type !== 'content_block_delta' @@ -403,30 +406,40 @@ export abstract class BaseRunner { } /** - * Converts an Anthropic request to an Anthropic API request body. + * Converts an Anthropic request to a non-streaming Anthropic API request body. * @param modelName The name of the Anthropic model to use. * @param request The Genkit GenerateRequest to convert. - * @param stream Whether to stream the response. * @param cacheSystemPrompt Whether to cache the system prompt. - * @returns The converted Anthropic API request body. + * @returns The converted Anthropic API non-streaming request body. * @throws An error if an unsupported output format is requested. */ protected abstract toAnthropicRequestBody( modelName: string, request: GenerateRequest, - stream?: boolean, cacheSystemPrompt?: boolean ): RunnerRequestBody; + /** + * Converts an Anthropic request to a streaming Anthropic API request body. + * @param modelName The name of the Anthropic model to use. + * @param request The Genkit GenerateRequest to convert. + * @param cacheSystemPrompt Whether to cache the system prompt. + * @returns The converted Anthropic API streaming request body. + * @throws An error if an unsupported output format is requested. + */ + protected abstract toAnthropicStreamingRequestBody( + modelName: string, + request: GenerateRequest, + cacheSystemPrompt?: boolean + ): RunnerStreamingRequestBody; + protected abstract createMessage( body: RunnerRequestBody, abortSignal: AbortSignal - ): - | Promise | RunnerStream> - | PromiseLike | RunnerStream>; + ): Promise>; protected abstract streamMessages( - body: RunnerRequestBody, + body: RunnerStreamingRequestBody, abortSignal: AbortSignal ): RunnerStream; @@ -448,14 +461,12 @@ export abstract class BaseRunner { ): Promise { const { streamingRequested, sendChunk, abortSignal } = options; - const body = this.toAnthropicRequestBody( - this.name, - request, - streamingRequested, - this.cacheSystemPrompt - ); - if (streamingRequested) { + const body = this.toAnthropicStreamingRequestBody( + this.name, + request, + this.cacheSystemPrompt + ); const stream = this.streamMessages(body, abortSignal); for await (const event of stream) { const part = this.toGenkitPart(event); @@ -470,17 +481,12 @@ export abstract class BaseRunner { return this.toGenkitResponse(finalMessage); } + const body = this.toAnthropicRequestBody( + this.name, + request, + this.cacheSystemPrompt + ); const response = await this.createMessage(body, abortSignal); - // Type narrowing: ensure we got a message, not a stream - if ( - typeof response === 'object' && - response !== null && - 'finalMessage' in response - ) { - throw new Error( - 'Unexpected stream returned from non-streaming createMessage request' - ); - } - return this.toGenkitResponse(response as RunnerMessage); + return this.toGenkitResponse(response); } } diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index d7e745c050..4dc025974d 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -17,18 +17,21 @@ import { Anthropic } from '@anthropic-ai/sdk'; import type { BetaContentBlock, - BetaContentBlockParam, BetaImageBlockParam, BetaMCPToolUseBlock, BetaMessage, MessageCreateParams as BetaMessageCreateParams, + MessageCreateParamsNonStreaming as BetaMessageCreateParamsNonStreaming, + MessageCreateParamsStreaming as BetaMessageCreateParamsStreaming, BetaMessageParam, BetaRawMessageStreamEvent, + BetaRequestDocumentBlock, BetaServerToolUseBlock, BetaStopReason, BetaTextBlockParam, BetaToolResultBlockParam, BetaToolUseBlock, + BetaToolUseBlockParam, } from '@anthropic-ai/sdk/resources/beta/messages'; import type { GenerateRequest, @@ -39,7 +42,7 @@ import type { import { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream.js'; import type { BetaTool } from '@anthropic-ai/sdk/resources/beta/messages'; -import { SUPPORTED_CLAUDE_MODELS } from '../models.js'; +import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema } from '../types.js'; import { BaseRunner } from './base.js'; @@ -52,7 +55,8 @@ type BetaRunnerTypes = { Message: BetaMessage; Stream: BetaMessageStream; StreamEvent: BetaRawMessageStreamEvent; - RequestBody: BetaMessageCreateParams; + RequestBody: BetaMessageCreateParamsNonStreaming; + StreamingRequestBody: BetaMessageCreateParamsStreaming; Tool: BetaTool; MessageParam: BetaMessageParam; ToolResponseContent: BetaTextBlockParam | BetaImageBlockParam; @@ -66,7 +70,14 @@ export class BetaRunner extends BaseRunner { super(name, client, cacheSystemPrompt); } - protected toAnthropicMessageContent(part: Part): BetaContentBlockParam { + protected toAnthropicMessageContent( + part: Part + ): + | BetaTextBlockParam + | BetaImageBlockParam + | BetaRequestDocumentBlock + | BetaToolUseBlockParam + | BetaToolResultBlockParam { if (part.text) { return { type: 'text', @@ -129,17 +140,16 @@ export class BetaRunner extends BaseRunner { } protected createMessage( - body: BetaMessageCreateParams, + body: BetaMessageCreateParamsNonStreaming, abortSignal: AbortSignal - ): Promise { - // TODO: try to avoid cast + ): Promise { return this.client.beta.messages.create(body, { signal: abortSignal, - }) as Promise; + }); } protected streamMessages( - body: BetaMessageCreateParams, + body: BetaMessageCreateParamsStreaming, abortSignal: AbortSignal ): BetaMessageStream { return this.client.beta.messages.stream(body, { @@ -150,11 +160,10 @@ export class BetaRunner extends BaseRunner { protected toAnthropicRequestBody( modelName: string, request: GenerateRequest, - stream?: boolean, cacheSystemPrompt?: boolean - ): BetaMessageCreateParams { + ): BetaMessageCreateParamsNonStreaming { // Use supported model ref if available for version mapping, otherwise use modelName directly - const model = SUPPORTED_CLAUDE_MODELS[modelName]; + const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = request.config?.version ?? model?.version ?? modelName; @@ -173,7 +182,7 @@ export class BetaRunner extends BaseRunner { ] : system; - const body: BetaMessageCreateParams = { + const body: BetaMessageCreateParamsNonStreaming = { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, @@ -183,8 +192,74 @@ export class BetaRunner extends BaseRunner { if (betaSystem !== undefined) { body.system = betaSystem; } - if (stream !== undefined) { - body.stream = stream as false; + if (request.config?.stopSequences !== undefined) { + body.stop_sequences = request.config.stopSequences; + } + if (request.config?.temperature !== undefined) { + body.temperature = request.config.temperature; + } + if (request.config?.topK !== undefined) { + body.top_k = request.config.topK; + } + if (request.config?.topP !== undefined) { + body.top_p = request.config.topP; + } + if (request.config?.tool_choice !== undefined) { + body.tool_choice = request.config + .tool_choice as BetaMessageCreateParams['tool_choice']; + } + if (request.config?.metadata !== undefined) { + body.metadata = request.config + .metadata as BetaMessageCreateParams['metadata']; + } + if (request.tools) { + body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); + } + + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + + return body; + } + + protected toAnthropicStreamingRequestBody( + modelName: string, + request: GenerateRequest, + cacheSystemPrompt?: boolean + ): BetaMessageCreateParamsStreaming { + // Use supported model ref if available for version mapping, otherwise use modelName directly + const model = KNOWN_CLAUDE_MODELS[modelName]; + const { system, messages } = this.toAnthropicMessages(request.messages); + const mappedModelName = + request.config?.version ?? model?.version ?? modelName; + + // Convert system to beta format with cache control if needed + const betaSystem = + system === undefined + ? undefined + : cacheSystemPrompt + ? [ + { + type: 'text' as const, + text: system, + cache_control: { type: 'ephemeral' as const }, + }, + ] + : system; + + const body: BetaMessageCreateParamsStreaming = { + model: mappedModelName, + max_tokens: + request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, + messages: messages, + stream: true, + }; + + if (betaSystem !== undefined) { + body.system = betaSystem; } if (request.config?.stopSequences !== undefined) { body.stop_sequences = request.config.stopSequences; diff --git a/js/plugins/anthropic/src/runner/runner.ts b/js/plugins/anthropic/src/runner/runner.ts index 5068f87f62..6231592971 100644 --- a/js/plugins/anthropic/src/runner/runner.ts +++ b/js/plugins/anthropic/src/runner/runner.ts @@ -20,7 +20,8 @@ import type { DocumentBlockParam, ImageBlockParam, Message, - MessageCreateParams, + MessageCreateParamsNonStreaming, + MessageCreateParamsStreaming, MessageParam, MessageStreamEvent, TextBlockParam, @@ -30,7 +31,7 @@ import type { } from '@anthropic-ai/sdk/resources/messages'; import type { GenerateRequest, GenerateResponseData, Part } from 'genkit'; -import { SUPPORTED_CLAUDE_MODELS } from '../models.js'; +import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema } from '../types.js'; import { BaseRunner } from './base.js'; @@ -38,7 +39,8 @@ type RunnerTypes = { Message: Message; Stream: MessageStream; StreamEvent: MessageStreamEvent; - RequestBody: MessageCreateParams; + RequestBody: MessageCreateParamsNonStreaming; + StreamingRequestBody: MessageCreateParamsStreaming; Tool: Tool; MessageParam: MessageParam; ToolResponseContent: TextBlockParam | ImageBlockParam; @@ -121,11 +123,10 @@ export class Runner extends BaseRunner { protected toAnthropicRequestBody( modelName: string, request: GenerateRequest, - stream?: boolean, cacheSystemPrompt?: boolean - ): MessageCreateParams { + ): MessageCreateParamsNonStreaming { // Use supported model ref if available for version mapping, otherwise use modelName directly - const model = SUPPORTED_CLAUDE_MODELS[modelName]; + const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = request.config?.version ?? model?.version ?? modelName; @@ -141,7 +142,7 @@ export class Runner extends BaseRunner { }, ] : system; - const body: MessageCreateParams = { + const body: MessageCreateParamsNonStreaming = { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, @@ -152,9 +153,70 @@ export class Runner extends BaseRunner { body.system = systemValue; } - if (stream !== undefined) { - body.stream = stream as false; + if (request.tools) { + body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); + } + if (request.config?.topK !== undefined) { + body.top_k = request.config.topK; + } + if (request.config?.topP !== undefined) { + body.top_p = request.config.topP; + } + if (request.config?.temperature !== undefined) { + body.temperature = request.config.temperature; + } + if (request.config?.stopSequences !== undefined) { + body.stop_sequences = request.config.stopSequences; + } + if (request.config?.metadata !== undefined) { + body.metadata = request.config.metadata; + } + if (request.config?.tool_choice !== undefined) { + body.tool_choice = request.config.tool_choice; } + + if (request.output?.format && request.output.format !== 'text') { + throw new Error( + `Only text output format is supported for Claude models currently` + ); + } + return body; + } + + protected toAnthropicStreamingRequestBody( + modelName: string, + request: GenerateRequest, + cacheSystemPrompt?: boolean + ): MessageCreateParamsStreaming { + // Use supported model ref if available for version mapping, otherwise use modelName directly + const model = KNOWN_CLAUDE_MODELS[modelName]; + const { system, messages } = this.toAnthropicMessages(request.messages); + const mappedModelName = + request.config?.version ?? model?.version ?? modelName; + const systemValue = + system === undefined + ? undefined + : cacheSystemPrompt + ? [ + { + type: 'text' as const, + text: system, + cache_control: { type: 'ephemeral' as const }, + }, + ] + : system; + const body: MessageCreateParamsStreaming = { + model: mappedModelName, + max_tokens: + request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, + messages, + stream: true, + }; + + if (systemValue !== undefined) { + body.system = systemValue; + } + if (request.tools) { body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); } @@ -185,17 +247,17 @@ export class Runner extends BaseRunner { return body; } - protected createMessage( - body: MessageCreateParams, + protected async createMessage( + body: MessageCreateParamsNonStreaming, abortSignal: AbortSignal - ): Promise { - return this.client.messages.create(body, { + ): Promise { + return await this.client.messages.create(body, { signal: abortSignal, - }) as Promise; + }); } protected streamMessages( - body: MessageCreateParams, + body: MessageCreateParamsStreaming, abortSignal: AbortSignal ): MessageStream { return this.client.messages.stream(body, { diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index d115115e11..1272360c07 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -18,7 +18,7 @@ import * as assert from 'assert'; import { genkit } from 'genkit'; import { describe, it } from 'node:test'; import anthropic from '../src/index.js'; -import { SUPPORTED_CLAUDE_MODELS } from '../src/models.js'; +import { KNOWN_CLAUDE_MODELS } from '../src/models.js'; import { PluginOptions, __testClient } from '../src/types.js'; import { createMockAnthropicClient } from './mocks/anthropic-client.js'; @@ -30,7 +30,7 @@ describe('Anthropic Plugin', () => { plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], }); - for (const modelName of Object.keys(SUPPORTED_CLAUDE_MODELS)) { + for (const modelName of Object.keys(KNOWN_CLAUDE_MODELS)) { const modelPath = `/model/anthropic/${modelName}`; const expectedBaseName = `anthropic/${modelName}`; const model = await ai.registry.lookupAction(modelPath); diff --git a/js/plugins/anthropic/tests/mocks/anthropic-client.ts b/js/plugins/anthropic/tests/mocks/anthropic-client.ts index 39d2cb70d8..321df8f24f 100644 --- a/js/plugins/anthropic/tests/mocks/anthropic-client.ts +++ b/js/plugins/anthropic/tests/mocks/anthropic-client.ts @@ -87,7 +87,7 @@ export function createMockAnthropicClient( ? mock.fn(() => { throw options.shouldError; }) - : mock.fn((body: any, opts?: { signal?: AbortSignal }) => { + : mock.fn((_body: any, opts?: { signal?: AbortSignal }) => { // Check abort signal before starting stream if (opts?.signal?.aborted) { throw new Error('AbortError'); @@ -105,7 +105,7 @@ export function createMockAnthropicClient( ? mock.fn(() => { throw options.shouldError; }) - : mock.fn((body: any, opts?: { signal?: AbortSignal }) => { + : mock.fn((_body: any, opts?: { signal?: AbortSignal }) => { if (opts?.signal?.aborted) { throw new Error('AbortError'); } @@ -376,7 +376,7 @@ function toBetaMessage(message: Message): BetaMessage { cache_read_input_tokens: message.usage.cache_read_input_tokens, input_tokens: message.usage.input_tokens, output_tokens: message.usage.output_tokens, - server_tool_use: message.usage.server_tool_use, + server_tool_use: message.usage.server_tool_use as any, service_tier: message.usage.service_tier, }, }; diff --git a/js/plugins/anthropic/tests/converters_test.ts b/js/plugins/anthropic/tests/runner_test.ts similarity index 88% rename from js/plugins/anthropic/tests/converters_test.ts rename to js/plugins/anthropic/tests/runner_test.ts index 5dffe93015..201306ccc9 100644 --- a/js/plugins/anthropic/tests/converters_test.ts +++ b/js/plugins/anthropic/tests/runner_test.ts @@ -43,8 +43,39 @@ import { } from './mocks/anthropic-client.js'; // Test helper: Create a Runner instance for testing converter methods +// Type interface to access protected methods in tests +type RunnerProtectedMethods = { + toAnthropicRole: ( + role: Role, + toolMessageType?: 'tool_use' | 'tool_result' + ) => 'user' | 'assistant'; + toAnthropicToolResponseContent: (part: Part) => any; + toAnthropicMessageContent: (part: Part) => any; + toAnthropicMessages: (messages: MessageData[]) => { + system?: string; + messages: any[]; + }; + toAnthropicTool: (tool: ToolDefinition) => any; + toAnthropicRequestBody: ( + modelName: string, + request: GenerateRequest, + cacheSystemPrompt?: boolean + ) => any; + toAnthropicStreamingRequestBody: ( + modelName: string, + request: GenerateRequest, + cacheSystemPrompt?: boolean + ) => any; + fromAnthropicContentBlockChunk: ( + event: MessageStreamEvent + ) => Part | undefined; + fromAnthropicStopReason: (reason: Message['stop_reason']) => any; + fromAnthropicResponse: (message: Message) => GenerateResponseData; +}; + const mockClient = createMockAnthropicClient(); -const testRunner = new Runner('test-model', mockClient); +const testRunner = new Runner('test-model', mockClient) as Runner & + RunnerProtectedMethods; const createUsage = ( overrides: Partial = {} @@ -59,46 +90,6 @@ const createUsage = ( ...overrides, }); -// Helper functions to access protected methods for testing -const toAnthropicRole = ( - role: Role, - toolMessageType?: 'tool_use' | 'tool_result' -) => (testRunner as any).toAnthropicRole(role, toolMessageType); - -const toAnthropicToolResponseContent = (part: Part) => - (testRunner as any).toAnthropicToolResponseContent(part); - -const toAnthropicMessageContent = (part: Part) => - (testRunner as any).toAnthropicMessageContent(part); - -const toAnthropicMessages = (messages: MessageData[]) => - (testRunner as any).toAnthropicMessages(messages); - -const toAnthropicTool = (tool: ToolDefinition) => - (testRunner as any).toAnthropicTool(tool); - -const toAnthropicRequestBody = ( - modelName: string, - request: GenerateRequest, - stream?: boolean, - cacheSystemPrompt?: boolean -) => - (testRunner as any).toAnthropicRequestBody( - modelName, - request, - stream, - cacheSystemPrompt - ); - -const fromAnthropicContentBlockChunk = (event: MessageStreamEvent) => - (testRunner as any).fromAnthropicContentBlockChunk(event); - -const fromAnthropicStopReason = (reason: Message['stop_reason']) => - (testRunner as any).fromAnthropicStopReason(reason); - -const fromAnthropicResponse = (message: Message) => - (testRunner as any).fromAnthropicResponse(message); - describe('toAnthropicRole', () => { const testCases: { genkitRole: Role; @@ -131,7 +122,7 @@ describe('toAnthropicRole', () => { ? ` when toolMessageType is "${test.toolMessageType}"` : '' }`, () => { - const actualOutput = toAnthropicRole( + const actualOutput = testRunner.toAnthropicRole( test.genkitRole, test.toolMessageType ); @@ -141,7 +132,7 @@ describe('toAnthropicRole', () => { it('should throw an error for unknown roles', () => { assert.throws( - () => toAnthropicRole('unknown' as Role), + () => testRunner.toAnthropicRole('unknown' as Role), /Unsupported genkit role: unknown/ ); }); @@ -152,7 +143,7 @@ describe('toAnthropicToolResponseContent', () => { // toAnthropicToolResponseContent expects part.toolResponse to exist // but will just return stringified undefined/empty object if not const part: Part = { data: 'hi' } as Part; - const result = toAnthropicToolResponseContent(part); + const result = testRunner.toAnthropicToolResponseContent(part); assert.ok(result); assert.strictEqual(result.type, 'text'); }); @@ -162,7 +153,7 @@ describe('toAnthropicMessageContent', () => { it('should throw if a media part contains invalid media', () => { assert.throws( () => - toAnthropicMessageContent({ + testRunner.toAnthropicMessageContent({ media: { url: '', }, @@ -173,7 +164,7 @@ describe('toAnthropicMessageContent', () => { it('should throw if the provided part is invalid', () => { assert.throws( - () => toAnthropicMessageContent({ fake: 'part' } as Part), + () => testRunner.toAnthropicMessageContent({ fake: 'part' } as Part), /Unsupported genkit part fields encountered for current message role: {"fake":"part"}/ ); }); @@ -183,7 +174,7 @@ describe('toAnthropicMessageContent', () => { // This will fall through to image handling which requires data URLs assert.throws( () => - toAnthropicMessageContent({ + testRunner.toAnthropicMessageContent({ media: { url: 'https://example.com/document.pdf', // contentType missing - won't be recognized as PDF @@ -194,7 +185,7 @@ describe('toAnthropicMessageContent', () => { }); it('should handle PDF with base64 data URL correctly', () => { - const result = toAnthropicMessageContent({ + const result = testRunner.toAnthropicMessageContent({ media: { url: 'data:application/pdf;base64,JVBERi0xLjQKJ', contentType: 'application/pdf', @@ -212,7 +203,7 @@ describe('toAnthropicMessageContent', () => { }); it('should handle PDF with HTTP/HTTPS URL correctly', () => { - const result = toAnthropicMessageContent({ + const result = testRunner.toAnthropicMessageContent({ media: { url: 'https://example.com/document.pdf', contentType: 'application/pdf', @@ -624,7 +615,7 @@ describe('toAnthropicMessages', () => { for (const test of testCases) { it(test.should, () => { - const actualOutput = toAnthropicMessages(test.inputMessages); + const actualOutput = testRunner.toAnthropicMessages(test.inputMessages); assert.deepStrictEqual(actualOutput, test.expectedOutput); }); } @@ -643,7 +634,7 @@ describe('toAnthropicTool', () => { required: ['topic'], }, }; - const actualOutput = toAnthropicTool(tool); + const actualOutput = testRunner.toAnthropicTool(tool); assert.deepStrictEqual(actualOutput, { name: 'tellAJoke', description: 'Tell a joke', @@ -720,10 +711,27 @@ describe('fromAnthropicContentBlockChunk', () => { for (const test of testCases) { it(test.should, () => { - const actualOutput = fromAnthropicContentBlockChunk(test.event); + const actualOutput = testRunner.fromAnthropicContentBlockChunk( + test.event + ); assert.deepStrictEqual(actualOutput, test.expectedOutput); }); } + + it('should throw for unsupported tool input streaming deltas', () => { + assert.throws( + () => + testRunner.fromAnthropicContentBlockChunk({ + index: 0, + type: 'content_block_delta', + delta: { + type: 'input_json_delta', + partial_json: '{"foo":', + }, + } as MessageStreamEvent), + /Anthropic streaming tool input \(input_json_delta\) is not yet supported/ + ); + }); }); describe('fromAnthropicStopReason', () => { @@ -759,7 +767,9 @@ describe('fromAnthropicStopReason', () => { for (const test of testCases) { it(`should map Anthropic stop reason "${test.inputStopReason}" to Genkit finish reason "${test.expectedFinishReason}"`, () => { - const actualOutput = fromAnthropicStopReason(test.inputStopReason); + const actualOutput = testRunner.fromAnthropicStopReason( + test.inputStopReason + ); assert.strictEqual(actualOutput, test.expectedFinishReason); }); } @@ -864,7 +874,7 @@ describe('fromAnthropicResponse', () => { for (const test of testCases) { it(test.should, () => { - const actualOutput = fromAnthropicResponse(test.message); + const actualOutput = testRunner.fromAnthropicResponse(test.message); // Check custom field exists and is the message assert.ok(actualOutput.custom); assert.strictEqual(actualOutput.custom, test.message); @@ -958,7 +968,7 @@ describe('toAnthropicRequestBody', () => { ]; for (const test of testCases) { it(test.should, () => { - const actualOutput = toAnthropicRequestBody( + const actualOutput = testRunner.toAnthropicRequestBody( test.modelName, test.genkitRequest ); @@ -968,7 +978,7 @@ describe('toAnthropicRequestBody', () => { it('should accept any model name and use it directly', () => { // Following Google GenAI pattern: accept any model name, let API validate - const result = toAnthropicRequestBody('fake-model', { + const result = testRunner.toAnthropicRequestBody('fake-model', { messages: [], } as GenerateRequest); @@ -979,7 +989,7 @@ describe('toAnthropicRequestBody', () => { it('should throw if output format is not text', () => { assert.throws( () => - toAnthropicRequestBody('claude-3-5-haiku', { + testRunner.toAnthropicRequestBody('claude-3-5-haiku', { messages: [], tools: [], output: { format: 'media' }, @@ -998,10 +1008,9 @@ describe('toAnthropicRequestBody', () => { }; // Test with caching enabled - const outputWithCaching = toAnthropicRequestBody( + const outputWithCaching = testRunner.toAnthropicRequestBody( 'claude-3-5-haiku', request, - false, true ); assert.deepStrictEqual(outputWithCaching.system, [ @@ -1013,16 +1022,68 @@ describe('toAnthropicRequestBody', () => { ]); // Test with caching disabled - const outputWithoutCaching = toAnthropicRequestBody( + const outputWithoutCaching = testRunner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false + ); + assert.strictEqual( + outputWithoutCaching.system, + 'You are a helpful assistant' + ); + }); +}); + +describe('toAnthropicStreamingRequestBody', () => { + it('should set stream to true', () => { + const request: GenerateRequest = { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + output: { format: 'text' }, + }; + + const output = testRunner.toAnthropicStreamingRequestBody( + 'claude-3-5-haiku', + request + ); + + assert.strictEqual(output.stream, true); + assert.strictEqual(output.model, 'claude-3-5-haiku-latest'); + assert.strictEqual(output.max_tokens, 4096); + }); + + it('should support system prompt caching in streaming mode', () => { + const request: GenerateRequest = { + messages: [ + { role: 'system', content: [{ text: 'You are a helpful assistant' }] }, + { role: 'user', content: [{ text: 'Hello' }] }, + ], + output: { format: 'text' }, + }; + + const outputWithCaching = testRunner.toAnthropicStreamingRequestBody( + 'claude-3-5-haiku', + request, + true + ); + assert.deepStrictEqual(outputWithCaching.system, [ + { + type: 'text', + text: 'You are a helpful assistant', + cache_control: { type: 'ephemeral' }, + }, + ]); + assert.strictEqual(outputWithCaching.stream, true); + + const outputWithoutCaching = testRunner.toAnthropicStreamingRequestBody( 'claude-3-5-haiku', request, - false, false ); assert.strictEqual( outputWithoutCaching.system, 'You are a helpful assistant' ); + assert.strictEqual(outputWithoutCaching.stream, true); }); }); @@ -1053,7 +1114,7 @@ describe('claudeRunner', () => { { model: 'claude-3-5-haiku-latest', max_tokens: 4096, - stream: false, + messages: [], }, { signal: abortSignal, @@ -1098,6 +1159,7 @@ describe('claudeRunner', () => { { model: 'claude-3-5-haiku-latest', max_tokens: 4096, + messages: [], stream: true, }, { @@ -1124,7 +1186,7 @@ describe('claudeRunner', () => { await runner( { messages: [], - config: { beta: { enabled: true } }, + config: { beta: { enabled: true, filesApi: false, webSearch: false } }, }, { streamingRequested: false, sendChunk: () => {}, abortSignal } ); From 759fcdf3fcd941a56c748a9e943364b3ae91b867 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 10 Nov 2025 17:59:16 +0000 Subject: [PATCH 18/51] refactor(anthropic): get tool types from genkit messages instead --- js/plugins/anthropic/src/runner/base.ts | 34 ++++++--- js/plugins/anthropic/src/runner/beta.ts | 93 ++++++++++++----------- js/plugins/anthropic/src/runner/runner.ts | 24 ++++-- 3 files changed, 86 insertions(+), 65 deletions(-) diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index 629fda4410..1463344f71 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -52,6 +52,7 @@ export type RunnerTypes = { StreamingRequestBody: unknown; Tool: unknown; MessageParam: unknown; + ContentBlockParam: unknown; ToolResponseContent: unknown; }; @@ -247,7 +248,9 @@ export abstract class BaseRunner { * Converts a Genkit Part to the corresponding Anthropic content block. * Each runner implements this to return its specific API type. */ - protected abstract toAnthropicMessageContent(part: Part): any; + protected abstract toAnthropicMessageContent( + part: Part + ): TTypes['ContentBlockParam']; /** * Converts Genkit messages to Anthropic format. @@ -262,25 +265,32 @@ export abstract class BaseRunner { messages[0]?.role === 'system' ? messages[0].content?.[0]?.text : undefined; + const messagesToIterate = system ? messages.slice(1) : messages; const anthropicMsgs: RunnerMessageParam[] = []; + for (const message of messagesToIterate) { const msg = new GenkitMessage(message); + + // Detect tool message kind from Genkit Parts (no SDK typing needed) + const hadToolUse = msg.content.some((p) => !!p.toolRequest); + const hadToolResult = msg.content.some((p) => !!p.toolResponse); + + const toolMessageType = hadToolUse + ? ('tool_use' as const) + : hadToolResult + ? ('tool_result' as const) + : undefined; + + const role = this.toAnthropicRole(message.role, toolMessageType); + const content = msg.content.map((part) => this.toAnthropicMessageContent(part) ); - const toolMessageType = content.find( - (c: any) => c.type === 'tool_use' || c.type === 'tool_result' - ); - const role = this.toAnthropicRole( - message.role, - toolMessageType?.type as 'tool_use' | 'tool_result' | undefined - ); - anthropicMsgs.push({ - role: role, - content, - } as RunnerMessageParam); + + anthropicMsgs.push({ role, content } as RunnerMessageParam); } + return { system, messages: anthropicMsgs }; } diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 4dc025974d..22a84c4064 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -15,6 +15,7 @@ */ import { Anthropic } from '@anthropic-ai/sdk'; +import { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream.js'; import type { BetaContentBlock, BetaImageBlockParam, @@ -29,10 +30,12 @@ import type { BetaServerToolUseBlock, BetaStopReason, BetaTextBlockParam, + BetaTool, BetaToolResultBlockParam, BetaToolUseBlock, BetaToolUseBlockParam, } from '@anthropic-ai/sdk/resources/beta/messages'; + import type { GenerateRequest, GenerateResponseData, @@ -40,8 +43,6 @@ import type { Part, } from 'genkit'; -import { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream.js'; -import type { BetaTool } from '@anthropic-ai/sdk/resources/beta/messages'; import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema } from '../types.js'; import { BaseRunner } from './base.js'; @@ -60,6 +61,12 @@ type BetaRunnerTypes = { Tool: BetaTool; MessageParam: BetaMessageParam; ToolResponseContent: BetaTextBlockParam | BetaImageBlockParam; + ContentBlockParam: + | BetaTextBlockParam + | BetaImageBlockParam + | BetaRequestDocumentBlock + | BetaToolUseBlockParam + | BetaToolResultBlockParam; }; /** @@ -70,6 +77,11 @@ export class BetaRunner extends BaseRunner { super(name, client, cacheSystemPrompt); } + /** + * Map a Genkit Part -> Anthropic beta content block param. + * Supports: text, images (base64 data URLs), PDFs (document source), + * tool_use (client tool request), tool_result (client tool response). + */ protected toAnthropicMessageContent( part: Part ): @@ -78,12 +90,12 @@ export class BetaRunner extends BaseRunner { | BetaRequestDocumentBlock | BetaToolUseBlockParam | BetaToolResultBlockParam { + // Text if (part.text) { - return { - type: 'text', - text: part.text, - }; + return { type: 'text', text: part.text }; } + + // Media if (part.media) { if (part.media.contentType === 'application/pdf') { return { @@ -102,6 +114,8 @@ export class BetaRunner extends BaseRunner { }, }; } + + // Tool request (client tool use) if (part.toolRequest) { if (!part.toolRequest.ref) { throw new Error( @@ -117,6 +131,8 @@ export class BetaRunner extends BaseRunner { input: part.toolRequest.input, }; } + + // Tool response (client tool result) if (part.toolResponse) { if (!part.toolResponse.ref) { throw new Error( @@ -132,6 +148,7 @@ export class BetaRunner extends BaseRunner { }; return betaResult; } + throw new Error( `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( part @@ -143,32 +160,30 @@ export class BetaRunner extends BaseRunner { body: BetaMessageCreateParamsNonStreaming, abortSignal: AbortSignal ): Promise { - return this.client.beta.messages.create(body, { - signal: abortSignal, - }); + return this.client.beta.messages.create(body, { signal: abortSignal }); } protected streamMessages( body: BetaMessageCreateParamsStreaming, abortSignal: AbortSignal ): BetaMessageStream { - return this.client.beta.messages.stream(body, { - signal: abortSignal, - }); + return this.client.beta.messages.stream(body, { signal: abortSignal }); } + /** + * Build non-streaming request body. + */ protected toAnthropicRequestBody( modelName: string, request: GenerateRequest, cacheSystemPrompt?: boolean ): BetaMessageCreateParamsNonStreaming { - // Use supported model ref if available for version mapping, otherwise use modelName directly const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = request.config?.version ?? model?.version ?? modelName; - // Convert system to beta format with cache control if needed + // Convert system: either raw string or cached text block array const betaSystem = system === undefined ? undefined @@ -186,24 +201,16 @@ export class BetaRunner extends BaseRunner { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, - messages: messages, + messages, }; - if (betaSystem !== undefined) { - body.system = betaSystem; - } - if (request.config?.stopSequences !== undefined) { + if (betaSystem !== undefined) body.system = betaSystem; + if (request.config?.stopSequences !== undefined) body.stop_sequences = request.config.stopSequences; - } - if (request.config?.temperature !== undefined) { + if (request.config?.temperature !== undefined) body.temperature = request.config.temperature; - } - if (request.config?.topK !== undefined) { - body.top_k = request.config.topK; - } - if (request.config?.topP !== undefined) { - body.top_p = request.config.topP; - } + if (request.config?.topK !== undefined) body.top_k = request.config.topK; + if (request.config?.topP !== undefined) body.top_p = request.config.topP; if (request.config?.tool_choice !== undefined) { body.tool_choice = request.config .tool_choice as BetaMessageCreateParams['tool_choice']; @@ -225,18 +232,19 @@ export class BetaRunner extends BaseRunner { return body; } + /** + * Build streaming request body. + */ protected toAnthropicStreamingRequestBody( modelName: string, request: GenerateRequest, cacheSystemPrompt?: boolean ): BetaMessageCreateParamsStreaming { - // Use supported model ref if available for version mapping, otherwise use modelName directly const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = request.config?.version ?? model?.version ?? modelName; - // Convert system to beta format with cache control if needed const betaSystem = system === undefined ? undefined @@ -254,25 +262,17 @@ export class BetaRunner extends BaseRunner { model: mappedModelName, max_tokens: request.config?.maxOutputTokens ?? this.DEFAULT_MAX_OUTPUT_TOKENS, - messages: messages, + messages, stream: true, }; - if (betaSystem !== undefined) { - body.system = betaSystem; - } - if (request.config?.stopSequences !== undefined) { + if (betaSystem !== undefined) body.system = betaSystem; + if (request.config?.stopSequences !== undefined) body.stop_sequences = request.config.stopSequences; - } - if (request.config?.temperature !== undefined) { + if (request.config?.temperature !== undefined) body.temperature = request.config.temperature; - } - if (request.config?.topK !== undefined) { - body.top_k = request.config.topK; - } - if (request.config?.topP !== undefined) { - body.top_p = request.config.topP; - } + if (request.config?.topK !== undefined) body.top_k = request.config.topK; + if (request.config?.topP !== undefined) body.top_p = request.config.topP; if (request.config?.tool_choice !== undefined) { body.tool_choice = request.config .tool_choice as BetaMessageCreateParams['tool_choice']; @@ -327,6 +327,7 @@ export class BetaRunner extends BaseRunner { if (event.delta.type === 'thinking_delta') { return { text: event.delta.thinking }; } + // server/client tool input_json_delta not supported yet return undefined; } return undefined; @@ -353,7 +354,9 @@ export class BetaRunner extends BaseRunner { default: { const unknownType = (contentBlock as { type: string }).type; console.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/runner.ts b/js/plugins/anthropic/src/runner/runner.ts index 6231592971..46d2dd6260 100644 --- a/js/plugins/anthropic/src/runner/runner.ts +++ b/js/plugins/anthropic/src/runner/runner.ts @@ -44,6 +44,12 @@ type RunnerTypes = { Tool: Tool; MessageParam: MessageParam; ToolResponseContent: TextBlockParam | ImageBlockParam; + ContentBlockParam: + | TextBlockParam + | ImageBlockParam + | DocumentBlockParam + | ToolUseBlockParam + | ToolResultBlockParam; }; export class Runner extends BaseRunner { @@ -66,6 +72,7 @@ export class Runner extends BaseRunner { citations: null, }; } + if (part.media) { if (part.media.contentType === 'application/pdf') { return { @@ -84,6 +91,7 @@ export class Runner extends BaseRunner { }, }; } + if (part.toolRequest) { if (!part.toolRequest.ref) { throw new Error( @@ -99,6 +107,7 @@ export class Runner extends BaseRunner { input: part.toolRequest.input, }; } + if (part.toolResponse) { if (!part.toolResponse.ref) { throw new Error( @@ -113,6 +122,7 @@ export class Runner extends BaseRunner { content: [this.toAnthropicToolResponseContent(part)], }; } + throw new Error( `Unsupported genkit part fields encountered for current message role: ${JSON.stringify( part @@ -125,11 +135,11 @@ export class Runner extends BaseRunner { request: GenerateRequest, cacheSystemPrompt?: boolean ): MessageCreateParamsNonStreaming { - // Use supported model ref if available for version mapping, otherwise use modelName directly const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = request.config?.version ?? model?.version ?? modelName; + const systemValue = system === undefined ? undefined @@ -142,6 +152,7 @@ export class Runner extends BaseRunner { }, ] : system; + const body: MessageCreateParamsNonStreaming = { model: mappedModelName, max_tokens: @@ -188,11 +199,11 @@ export class Runner extends BaseRunner { request: GenerateRequest, cacheSystemPrompt?: boolean ): MessageCreateParamsStreaming { - // Use supported model ref if available for version mapping, otherwise use modelName directly const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = request.config?.version ?? model?.version ?? modelName; + const systemValue = system === undefined ? undefined @@ -205,6 +216,7 @@ export class Runner extends BaseRunner { }, ] : system; + const body: MessageCreateParamsStreaming = { model: mappedModelName, max_tokens: @@ -251,18 +263,14 @@ export class Runner extends BaseRunner { body: MessageCreateParamsNonStreaming, abortSignal: AbortSignal ): Promise { - return await this.client.messages.create(body, { - signal: abortSignal, - }); + return await this.client.messages.create(body, { signal: abortSignal }); } protected streamMessages( body: MessageCreateParamsStreaming, abortSignal: AbortSignal ): MessageStream { - return this.client.messages.stream(body, { - signal: abortSignal, - }); + return this.client.messages.stream(body, { signal: abortSignal }); } protected toGenkitResponse(message: Message): GenerateResponseData { From 031dc25381e84ccabe6e0abee7ff8943e48c40f1 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 10 Nov 2025 18:19:06 +0000 Subject: [PATCH 19/51] refactor(anthropic): provide beta flag in options and at request config level --- js/plugins/anthropic/src/index.ts | 15 ++- js/plugins/anthropic/src/models.ts | 13 ++- js/plugins/anthropic/src/types.ts | 29 ++++-- js/plugins/anthropic/tests/runner_test.ts | 110 +++++++++++++++++++++- js/plugins/anthropic/tests/types_test.ts | 89 +++++++++++++++++ 5 files changed, 240 insertions(+), 16 deletions(-) create mode 100644 js/plugins/anthropic/tests/types_test.ts diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 03812f2c11..e1650a065f 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -130,6 +130,7 @@ function getAnthropicClient(options?: PluginOptions): Anthropic { */ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { const client = getAnthropicClient(options); + const defaultApiVersion = options?.apiVersion; let listActionsCache: ActionMetadata[] | null = null; @@ -138,7 +139,12 @@ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { init: async () => { const actions: ModelAction[] = []; for (const name of Object.keys(KNOWN_CLAUDE_MODELS)) { - const action = claudeModel(name, client, options?.cacheSystemPrompt); + const action = claudeModel( + name, + client, + options?.cacheSystemPrompt, + defaultApiVersion + ); actions.push(action); } return actions; @@ -148,7 +154,12 @@ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { // Strip the 'anthropic/' namespace prefix if present const modelName = name.startsWith('anthropic/') ? name.slice(10) : name; // Follow Google GenAI pattern: accept any model name and let the API validate it - return claudeModel(modelName, client, options?.cacheSystemPrompt); + return claudeModel( + modelName, + client, + options?.cacheSystemPrompt, + defaultApiVersion + ); } return undefined; }, diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 3f02434ffa..8deaf755f4 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -27,7 +27,7 @@ import { modelRef } from 'genkit/model'; import { model } from 'genkit/plugin'; import { BetaRunner, Runner } from './runner/index.js'; -import { AnthropicConfigSchema } from './types.js'; +import { AnthropicConfigSchema, resolveBetaEnabled } from './types.js'; export const claude4Sonnet = modelRef({ name: 'claude-4-sonnet', @@ -178,12 +178,14 @@ export type ClaudeConfig = z.infer; * @param name The name of the Claude model. * @param client The Anthropic client instance. * @param cacheSystemPrompt Whether to cache the system prompt. + * @param defaultApiVersion Plugin-wide default API surface. * @returns The runner that Genkit will call when the model is invoked. */ export function claudeRunner( name: string, client: Anthropic, - cacheSystemPrompt?: boolean + cacheSystemPrompt?: boolean, + defaultApiVersion?: 'stable' | 'beta' ) { return async ( request: GenerateRequest, @@ -197,7 +199,7 @@ export function claudeRunner( abortSignal: AbortSignal; } ): Promise => { - const isBeta = request.config?.beta?.enabled ?? false; + const isBeta = resolveBetaEnabled(request.config, defaultApiVersion); const api = isBeta ? new BetaRunner(name, client, cacheSystemPrompt) : new Runner(name, client, cacheSystemPrompt); @@ -257,7 +259,8 @@ export function claudeModelReference( export function claudeModel( name: string, client: Anthropic, - cacheSystemPrompt?: boolean + cacheSystemPrompt?: boolean, + defaultApiVersion?: 'stable' | 'beta' ): ModelAction { // Use supported model ref if available, otherwise create generic model ref const modelRef = KNOWN_CLAUDE_MODELS[name]; @@ -269,6 +272,6 @@ export function claudeModel( ...modelInfo, configSchema: AnthropicConfigSchema, }, - claudeRunner(name, client, cacheSystemPrompt) + claudeRunner(name, client, cacheSystemPrompt, defaultApiVersion) ); } diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 8260f2bcb5..b36dcfaa93 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -31,6 +31,8 @@ export const __testClient = Symbol('testClient'); export interface PluginOptions { apiKey?: string; cacheSystemPrompt?: boolean; + /** Default API surface for all requests unless overridden per-request. */ + apiVersion?: 'stable' | 'beta'; } /** @@ -61,13 +63,8 @@ export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ user_id: z.string().optional(), }) .optional(), - beta: z - .object({ - enabled: z.boolean().default(false), - filesApi: z.boolean().default(false), - webSearch: z.boolean().default(false), - }) - .optional(), + /** Optional shorthand to pick API surface for this request. */ + apiVersion: z.enum(['stable', 'beta']).optional(), }); /** @@ -98,3 +95,21 @@ export const MEDIA_TYPES = { GIF: 'image/gif', WEBP: 'image/webp', } as const satisfies Record; + +/** + * Resolve whether beta API should be used for this call. + * Priority: + * 1. request.config.apiVersion (per-request override - explicit stable or beta) + * 2. pluginDefaultApiVersion (plugin-wide default) + * 3. otherwise stable + */ +export function resolveBetaEnabled( + cfg: z.infer | undefined, + pluginDefaultApiVersion?: 'stable' | 'beta' +): boolean { + if (cfg?.apiVersion !== undefined) { + return cfg.apiVersion === 'beta'; + } + if (pluginDefaultApiVersion === 'beta') return true; + return false; +} diff --git a/js/plugins/anthropic/tests/runner_test.ts b/js/plugins/anthropic/tests/runner_test.ts index 201306ccc9..72aa81a7bf 100644 --- a/js/plugins/anthropic/tests/runner_test.ts +++ b/js/plugins/anthropic/tests/runner_test.ts @@ -1168,7 +1168,7 @@ describe('claudeRunner', () => { ]); }); - it('should use beta API when beta.enabled is true', async () => { + it('should use beta API when apiVersion is beta', async () => { const mockClient = createMockAnthropicClient({ messageResponse: { content: [{ type: 'text', text: 'response', citations: null }], @@ -1186,7 +1186,7 @@ describe('claudeRunner', () => { await runner( { messages: [], - config: { beta: { enabled: true, filesApi: false, webSearch: false } }, + config: { apiVersion: 'beta' }, }, { streamingRequested: false, sendChunk: () => {}, abortSignal } ); @@ -1197,6 +1197,112 @@ describe('claudeRunner', () => { const regularCreateStub = mockClient.messages.create as any; assert.strictEqual(regularCreateStub.mock.calls.length, 0); }); + + it('should use beta API when defaultApiVersion is beta', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: { + content: [{ type: 'text', text: 'response', citations: null }], + usage: createUsage({ + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + }, + }); + + const runner = claudeRunner( + 'claude-3-5-haiku', + mockClient, + undefined, + 'beta' + ); + const abortSignal = new AbortController().signal; + await runner( + { + messages: [], + }, + { streamingRequested: false, sendChunk: () => {}, abortSignal } + ); + + const betaCreateStub = mockClient.beta.messages.create as any; + assert.strictEqual(betaCreateStub.mock.calls.length, 1); + + const regularCreateStub = mockClient.messages.create as any; + assert.strictEqual(regularCreateStub.mock.calls.length, 0); + }); + + it('should use request apiVersion over defaultApiVersion', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: { + content: [{ type: 'text', text: 'response', citations: null }], + usage: createUsage({ + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + }, + }); + + // defaultApiVersion is 'stable', but request overrides to 'beta' + const runner = claudeRunner( + 'claude-3-5-haiku', + mockClient, + undefined, + 'stable' + ); + const abortSignal = new AbortController().signal; + await runner( + { + messages: [], + config: { apiVersion: 'beta' }, + }, + { streamingRequested: false, sendChunk: () => {}, abortSignal } + ); + + const betaCreateStub = mockClient.beta.messages.create as any; + assert.strictEqual(betaCreateStub.mock.calls.length, 1); + + const regularCreateStub = mockClient.messages.create as any; + assert.strictEqual(regularCreateStub.mock.calls.length, 0); + }); + + it('should use stable API when defaultApiVersion is beta but request overrides to stable', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: { + content: [{ type: 'text', text: 'response', citations: null }], + usage: createUsage({ + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }), + }, + }); + + // defaultApiVersion is 'beta', but request overrides to 'stable' + const runner = claudeRunner( + 'claude-3-5-haiku', + mockClient, + undefined, + 'beta' + ); + const abortSignal = new AbortController().signal; + await runner( + { + messages: [], + config: { apiVersion: 'stable' }, + }, + { streamingRequested: false, sendChunk: () => {}, abortSignal } + ); + + const betaCreateStub = mockClient.beta.messages.create as any; + assert.strictEqual(betaCreateStub.mock.calls.length, 0); + + const regularCreateStub = mockClient.messages.create as any; + assert.strictEqual(regularCreateStub.mock.calls.length, 1); + }); }); describe('claudeModel', () => { diff --git a/js/plugins/anthropic/tests/types_test.ts b/js/plugins/anthropic/tests/types_test.ts new file mode 100644 index 0000000000..64c91e1547 --- /dev/null +++ b/js/plugins/anthropic/tests/types_test.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 * as assert from 'assert'; +import { z } from 'genkit'; +import { describe, it } from 'node:test'; +import { AnthropicConfigSchema, resolveBetaEnabled } from '../src/types.js'; + +describe('resolveBetaEnabled', () => { + it('should return true when config.apiVersion is beta', () => { + const config: z.infer = { + apiVersion: 'beta', + }; + assert.strictEqual(resolveBetaEnabled(config, 'stable'), true); + }); + + it('should return true when pluginDefaultApiVersion is beta', () => { + assert.strictEqual(resolveBetaEnabled(undefined, 'beta'), true); + }); + + it('should return false when config.apiVersion is stable', () => { + const config: z.infer = { + apiVersion: 'stable', + }; + assert.strictEqual(resolveBetaEnabled(config, 'stable'), false); + }); + + it('should return false when both are stable', () => { + const config: z.infer = { + apiVersion: 'stable', + }; + assert.strictEqual(resolveBetaEnabled(config, 'stable'), false); + }); + + it('should return false when neither is specified', () => { + assert.strictEqual(resolveBetaEnabled(undefined, undefined), false); + }); + + it('should return false when config is undefined and plugin default is stable', () => { + assert.strictEqual(resolveBetaEnabled(undefined, 'stable'), false); + }); + + it('should prioritize config.apiVersion over pluginDefaultApiVersion (beta over stable)', () => { + const config: z.infer = { + apiVersion: 'beta', + }; + // Even though plugin default is stable, request config should override + assert.strictEqual(resolveBetaEnabled(config, 'stable'), true); + }); + + it('should prioritize config.apiVersion over pluginDefaultApiVersion (stable over beta)', () => { + const config: z.infer = { + apiVersion: 'stable', + }; + // Request explicitly wants stable, should override plugin default + assert.strictEqual(resolveBetaEnabled(config, 'beta'), false); + }); + + it('should return false when config is empty object', () => { + const config: z.infer = {}; + assert.strictEqual(resolveBetaEnabled(config, undefined), false); + }); + + it('should return true when config is empty but plugin default is beta', () => { + const config: z.infer = {}; + assert.strictEqual(resolveBetaEnabled(config, 'beta'), true); + }); + + it('should handle config with other fields but no apiVersion', () => { + const config: z.infer = { + metadata: { user_id: 'test-user' }, + }; + assert.strictEqual(resolveBetaEnabled(config, 'stable'), false); + assert.strictEqual(resolveBetaEnabled(config, 'beta'), true); + }); +}); From e4e486067dd7f8a7a15b53e39e1fca38181621a9 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 10 Nov 2025 18:33:10 +0000 Subject: [PATCH 20/51] refactor(anthropic): extract out types.ts for runner --- js/plugins/anthropic/src/runner/base.ts | 39 ++++++----------- js/plugins/anthropic/src/runner/types.ts | 55 ++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 26 deletions(-) create mode 100644 js/plugins/anthropic/src/runner/types.ts diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index 1463344f71..a41e3fbc53 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -41,31 +41,18 @@ import { MediaTypeSchema, } from '../types.js'; -/** - * Type constraint for runner type parameters. - */ -export type RunnerTypes = { - Message: unknown; - Stream: AsyncIterable & { finalMessage(): Promise }; - StreamEvent: unknown; - RequestBody: unknown; - StreamingRequestBody: unknown; - Tool: unknown; - MessageParam: unknown; - ContentBlockParam: unknown; - ToolResponseContent: unknown; -}; - -type RunnerMessage = T['Message']; -type RunnerStream = T['Stream']; -type RunnerStreamEvent = T['StreamEvent']; -type RunnerRequestBody = T['RequestBody']; -type RunnerStreamingRequestBody = - T['StreamingRequestBody']; -type RunnerTool = T['Tool']; -type RunnerMessageParam = T['MessageParam']; -type RunnerToolResponseContent = - T['ToolResponseContent']; +import { + RunnerContentBlockParam, + RunnerMessage, + RunnerMessageParam, + RunnerRequestBody, + RunnerStream, + RunnerStreamEvent, + RunnerStreamingRequestBody, + RunnerTool, + RunnerToolResponseContent, + RunnerTypes, +} from './types.js'; export abstract class BaseRunner { protected name: string; @@ -250,7 +237,7 @@ export abstract class BaseRunner { */ protected abstract toAnthropicMessageContent( part: Part - ): TTypes['ContentBlockParam']; + ): RunnerContentBlockParam; /** * Converts Genkit messages to Anthropic format. diff --git a/js/plugins/anthropic/src/runner/types.ts b/js/plugins/anthropic/src/runner/types.ts new file mode 100644 index 0000000000..59ed610647 --- /dev/null +++ b/js/plugins/anthropic/src/runner/types.ts @@ -0,0 +1,55 @@ +/** + * 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. + */ + +/** + * Type constraint for runner type parameters. + */ +type RunnerTypes = { + Message: unknown; + Stream: AsyncIterable & { finalMessage(): Promise }; + StreamEvent: unknown; + RequestBody: unknown; + StreamingRequestBody: unknown; + Tool: unknown; + MessageParam: unknown; + ContentBlockParam: unknown; + ToolResponseContent: unknown; +}; + +type RunnerMessage = T['Message']; +type RunnerStream = T['Stream']; +type RunnerStreamEvent = T['StreamEvent']; +type RunnerRequestBody = T['RequestBody']; +type RunnerStreamingRequestBody = + T['StreamingRequestBody']; +type RunnerTool = T['Tool']; +type RunnerMessageParam = T['MessageParam']; +type RunnerContentBlockParam = T['ContentBlockParam']; +type RunnerToolResponseContent = + T['ToolResponseContent']; + +export { + RunnerContentBlockParam, + RunnerMessage, + RunnerMessageParam, + RunnerRequestBody, + RunnerStream, + RunnerStreamEvent, + RunnerStreamingRequestBody, + RunnerTool, + RunnerToolResponseContent, + RunnerTypes, +}; From 723783c746d0503aeefa78fc840de933270c8790 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 09:22:12 +0000 Subject: [PATCH 21/51] refactor(anthropic): hoist runners --- js/plugins/anthropic/src/models.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 8deaf755f4..ab936ca452 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -187,6 +187,9 @@ export function claudeRunner( cacheSystemPrompt?: boolean, defaultApiVersion?: 'stable' | 'beta' ) { + let stableRunner: Runner | null = null; + let betaRunner: BetaRunner | null = null; + return async ( request: GenerateRequest, { @@ -200,10 +203,10 @@ export function claudeRunner( } ): Promise => { const isBeta = resolveBetaEnabled(request.config, defaultApiVersion); - const api = isBeta - ? new BetaRunner(name, client, cacheSystemPrompt) - : new Runner(name, client, cacheSystemPrompt); - return api.run(request, { streamingRequested, sendChunk, abortSignal }); + const runner = isBeta + ? (betaRunner ??= new BetaRunner(name, client, cacheSystemPrompt)) + : (stableRunner ??= new Runner(name, client, cacheSystemPrompt)); + return runner.run(request, { streamingRequested, sendChunk, abortSignal }); }; } From 2fd1878ba417957f3bb0fec067a3d73d4450d6af Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 10:16:35 +0000 Subject: [PATCH 22/51] refactor(anthropic): rename classes and add more tests --- js/plugins/anthropic/src/index.ts | 19 +- js/plugins/anthropic/src/models.ts | 108 +++++- js/plugins/anthropic/src/runner/index.ts | 2 +- .../src/runner/{runner.ts => stable.ts} | 0 js/plugins/anthropic/src/types.ts | 20 + .../anthropic/tests/beta_runner_test.ts | 342 +++++++++++++++++ js/plugins/anthropic/tests/index_test.ts | 53 +++ .../anthropic/tests/integration_test.ts | 30 ++ js/plugins/anthropic/tests/mocks/README.md | 207 ---------- .../{runner_test.ts => stable_runner_test.ts} | 363 +++++++++++++++++- 10 files changed, 891 insertions(+), 253 deletions(-) rename js/plugins/anthropic/src/runner/{runner.ts => stable.ts} (100%) create mode 100644 js/plugins/anthropic/tests/beta_runner_test.ts delete mode 100644 js/plugins/anthropic/tests/mocks/README.md rename js/plugins/anthropic/tests/{runner_test.ts => stable_runner_test.ts} (80%) diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index e1650a065f..0227eccb70 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -139,12 +139,12 @@ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { init: async () => { const actions: ModelAction[] = []; for (const name of Object.keys(KNOWN_CLAUDE_MODELS)) { - const action = claudeModel( + const action = claudeModel({ name, client, - options?.cacheSystemPrompt, - defaultApiVersion - ); + cacheSystemPrompt: options?.cacheSystemPrompt, + defaultApiVersion, + }); actions.push(action); } return actions; @@ -153,13 +153,12 @@ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { if (actionType === 'model') { // Strip the 'anthropic/' namespace prefix if present const modelName = name.startsWith('anthropic/') ? name.slice(10) : name; - // Follow Google GenAI pattern: accept any model name and let the API validate it - return claudeModel( - modelName, + return claudeModel({ + name: modelName, client, - options?.cacheSystemPrompt, - defaultApiVersion - ); + cacheSystemPrompt: options?.cacheSystemPrompt, + defaultApiVersion, + }); } return undefined; }, diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index ab936ca452..df37747a97 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -27,7 +27,12 @@ import { modelRef } from 'genkit/model'; import { model } from 'genkit/plugin'; import { BetaRunner, Runner } from './runner/index.js'; -import { AnthropicConfigSchema, resolveBetaEnabled } from './types.js'; +import { + AnthropicConfigSchema, + resolveBetaEnabled, + type ClaudeModelParams, + type ClaudeRunnerParams, +} from './types.js'; export const claude4Sonnet = modelRef({ name: 'claude-4-sonnet', @@ -168,6 +173,22 @@ export const KNOWN_CLAUDE_MODELS: Record< 'claude-4-1-opus': claude41Opus, }; +/** + * Generic Claude model info for unknown/unsupported models. + * Used when a model name is not in SUPPORTED_CLAUDE_MODELS. + */ +const GENERIC_CLAUDE_MODEL_INFO = { + versions: [], + label: 'Anthropic - Claude', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, +}; + export type KnownClaudeModels = keyof typeof KNOWN_CLAUDE_MODELS; export type ClaudeModelName = string; export type AnthropicConfigSchemaType = typeof AnthropicConfigSchema; @@ -181,12 +202,46 @@ export type ClaudeConfig = z.infer; * @param defaultApiVersion Plugin-wide default API surface. * @returns The runner that Genkit will call when the model is invoked. */ +export function claudeRunner( + params: ClaudeRunnerParams +): ReturnType; export function claudeRunner( name: string, client: Anthropic, cacheSystemPrompt?: boolean, defaultApiVersion?: 'stable' | 'beta' +): ReturnType; +export function claudeRunner( + paramsOrName: ClaudeRunnerParams | string, + client?: Anthropic, + cacheSystemPrompt?: boolean, + defaultApiVersion?: 'stable' | 'beta' ) { + const params = + typeof paramsOrName === 'string' + ? { + name: paramsOrName, + client: + client ?? + (() => { + throw new Error( + 'Anthropic client is required to create a runner' + ); + })(), + cacheSystemPrompt, + defaultApiVersion, + } + : paramsOrName; + + return createClaudeRunner(params); +} + +function createClaudeRunner({ + name, + client, + cacheSystemPrompt, + defaultApiVersion, +}: ClaudeRunnerParams) { let stableRunner: Runner | null = null; let betaRunner: BetaRunner | null = null; @@ -210,22 +265,6 @@ export function claudeRunner( }; } -/** - * Generic Claude model info for unknown/unsupported models. - * Used when a model name is not in SUPPORTED_CLAUDE_MODELS. - */ -const GENERIC_CLAUDE_MODEL_INFO = { - versions: [], - label: 'Anthropic - Claude', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, -}; - /** * Creates a model reference for a Claude model. * This allows referencing models without initializing the plugin. @@ -256,15 +295,37 @@ export function claudeModelReference( /** * Defines a Claude model with the given name and Anthropic client. - * Accepts any model name and lets the API validate it. If the model is in SUPPORTED_CLAUDE_MODELS, uses that modelRef + * Accepts any model name and lets the API validate it. If the model is in KNOWN_CLAUDE_MODELS, uses that modelRef * for better defaults; otherwise creates a generic model reference. */ export function claudeModel( - name: string, - client: Anthropic, + paramsOrName: ClaudeModelParams | string, + client?: Anthropic, cacheSystemPrompt?: boolean, defaultApiVersion?: 'stable' | 'beta' ): ModelAction { + const params = + typeof paramsOrName === 'string' + ? { + name: paramsOrName, + client: + client ?? + (() => { + throw new Error( + 'Anthropic client is required to create a model action' + ); + })(), + cacheSystemPrompt, + defaultApiVersion, + } + : paramsOrName; + + const { + name, + client: runnerClient, + cacheSystemPrompt: cachePrompt, + defaultApiVersion: apiVersion, + } = params; // Use supported model ref if available, otherwise create generic model ref const modelRef = KNOWN_CLAUDE_MODELS[name]; const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO; @@ -275,6 +336,11 @@ export function claudeModel( ...modelInfo, configSchema: AnthropicConfigSchema, }, - claudeRunner(name, client, cacheSystemPrompt, defaultApiVersion) + claudeRunner({ + name, + client: runnerClient, + cacheSystemPrompt: cachePrompt, + defaultApiVersion: apiVersion, + }) ); } diff --git a/js/plugins/anthropic/src/runner/index.ts b/js/plugins/anthropic/src/runner/index.ts index 0872cee3fc..ce7e3c6fdd 100644 --- a/js/plugins/anthropic/src/runner/index.ts +++ b/js/plugins/anthropic/src/runner/index.ts @@ -16,4 +16,4 @@ export { BaseRunner } from './base.js'; export { BetaRunner } from './beta.js'; -export { Runner } from './runner.js'; +export { Runner } from './stable.js'; diff --git a/js/plugins/anthropic/src/runner/runner.ts b/js/plugins/anthropic/src/runner/stable.ts similarity index 100% rename from js/plugins/anthropic/src/runner/runner.ts rename to js/plugins/anthropic/src/runner/stable.ts diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index b36dcfaa93..4c503a3b6c 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -43,6 +43,26 @@ export interface InternalPluginOptions extends PluginOptions { [__testClient]?: Anthropic; } +/** + * Shared parameters required to construct Claude helpers. + */ +interface ClaudeHelperParamsBase { + name: string; + client: Anthropic; + cacheSystemPrompt?: boolean; + defaultApiVersion?: 'stable' | 'beta'; +} + +/** + * Parameters for creating a Claude model action. + */ +export interface ClaudeModelParams extends ClaudeHelperParamsBase {} + +/** + * Parameters for creating a Claude runner. + */ +export interface ClaudeRunnerParams extends ClaudeHelperParamsBase {} + export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ tool_choice: z .union([ diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts new file mode 100644 index 0000000000..77ee2de9d0 --- /dev/null +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -0,0 +1,342 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Anthropic } from '@anthropic-ai/sdk'; +import * as assert from 'assert'; +import { describe, it, mock } from 'node:test'; + +import { BetaRunner } from '../src/runner/beta.js'; +import { createMockAnthropicClient } from './mocks/anthropic-client.js'; + +describe('BetaRunner', () => { + it('should map all supported Part shapes to beta content blocks', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + + const exposed = runner as any; + + const textPart = exposed.toAnthropicMessageContent({ + text: 'Hello', + } as any); + assert.deepStrictEqual(textPart, { type: 'text', text: 'Hello' }); + + const pdfPart = exposed.toAnthropicMessageContent({ + media: { + url: 'data:application/pdf;base64,JVBERi0xLjQKJ', + contentType: 'application/pdf', + }, + } as any); + assert.strictEqual(pdfPart.type, 'document'); + + const imagePart = exposed.toAnthropicMessageContent({ + media: { + url: 'data:image/png;base64,AAA', + contentType: 'image/png', + }, + } as any); + assert.strictEqual(imagePart.type, 'image'); + + const toolUsePart = exposed.toAnthropicMessageContent({ + toolRequest: { + ref: 'tool1', + name: 'get_weather', + input: { city: 'NYC' }, + }, + } as any); + assert.deepStrictEqual(toolUsePart, { + type: 'tool_use', + id: 'tool1', + name: 'get_weather', + input: { city: 'NYC' }, + }); + + const toolResultPart = exposed.toAnthropicMessageContent({ + toolResponse: { + ref: 'tool1', + name: 'get_weather', + output: 'Sunny', + }, + } as any); + assert.strictEqual(toolResultPart.type, 'tool_result'); + + assert.throws(() => exposed.toAnthropicMessageContent({} as any)); + }); + + it('should convert beta stream events to Genkit Parts', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + + const exposed = runner as any; + const textPart = exposed.toGenkitPart({ + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: 'hi' }, + } as any); + assert.deepStrictEqual(textPart, { text: 'hi' }); + + const serverToolEvent = { + type: 'content_block_start', + index: 0, + content_block: { + type: 'server_tool_use', + id: 'toolu_test', + name: 'myTool', + input: { foo: 'bar' }, + server_name: 'srv', + }, + } as any; + const toolPart = exposed.toGenkitPart(serverToolEvent); + assert.deepStrictEqual(toolPart, { + toolRequest: { + ref: 'toolu_test', + name: 'srv/myTool', + input: { foo: 'bar' }, + }, + }); + + const deltaPart = exposed.toGenkitPart({ + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'hmm' }, + } as any); + assert.deepStrictEqual(deltaPart, { text: 'hmm' }); + + const ignored = exposed.toGenkitPart({ type: 'message_stop' } as any); + assert.strictEqual(ignored, undefined); + }); + + it('should map beta stop reasons correctly', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + + const finishReason = runner['fromBetaStopReason']( + 'model_context_window_exceeded' + ); + assert.strictEqual(finishReason, 'length'); + + const pauseReason = runner['fromBetaStopReason']('pause_turn'); + assert.strictEqual(pauseReason, 'stop'); + }); + + it('should execute streaming calls and surface errors', async () => { + const streamError = new Error('stream failed'); + const mockClient = createMockAnthropicClient({ + streamChunks: [ + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: 'hi' }, + } as any, + ], + streamErrorAfterChunk: 1, + streamError, + }); + + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const sendChunk = mock.fn(); + await assert.rejects(async () => + runner.run({ messages: [] } as any, { + streamingRequested: true, + sendChunk, + abortSignal: new AbortController().signal, + }) + ); + assert.strictEqual(sendChunk.mock.calls.length, 1); + + const abortController = new AbortController(); + abortController.abort(); + await assert.rejects(async () => + runner.run({ messages: [] } as any, { + streamingRequested: true, + sendChunk: () => {}, + abortSignal: abortController.signal, + }) + ); + }); + + it('should throw when tool refs are missing in message content', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const exposed = runner as any; + + assert.throws(() => + exposed.toAnthropicMessageContent({ + toolRequest: { + name: 'get_weather', + input: {}, + }, + } as any) + ); + + assert.throws(() => + exposed.toAnthropicMessageContent({ + toolResponse: { + name: 'get_weather', + output: 'ok', + }, + } as any) + ); + + assert.throws(() => + exposed.toAnthropicMessageContent({ + media: { + url: 'data:image/png;base64,', + contentType: undefined, + }, + } as any) + ); + }); + + it('should build request bodies with optional config fields', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner( + 'claude-3-5-haiku', + mockClient as Anthropic, + true + ) as any; + + const request = { + messages: [ + { + role: 'system', + content: [{ text: 'You are helpful.' }], + }, + { + role: 'user', + content: [{ text: 'Tell me a joke' }], + }, + ], + config: { + maxOutputTokens: 128, + topK: 4, + topP: 0.65, + temperature: 0.55, + stopSequences: ['DONE'], + metadata: { user_id: 'beta-user' }, + tool_choice: { type: 'tool', name: 'get_weather' }, + }, + tools: [ + { + name: 'get_weather', + description: 'Returns the weather', + inputSchema: { type: 'object' }, + }, + ], + } satisfies any; + + const body = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + true + ); + + assert.strictEqual(body.model, 'claude-3-5-haiku-latest'); + assert.ok(Array.isArray(body.system)); + assert.strictEqual(body.max_tokens, 128); + assert.strictEqual(body.top_k, 4); + assert.strictEqual(body.top_p, 0.65); + assert.strictEqual(body.temperature, 0.55); + assert.deepStrictEqual(body.stop_sequences, ['DONE']); + assert.deepStrictEqual(body.metadata, { user_id: 'beta-user' }); + assert.deepStrictEqual(body.tool_choice, { + type: 'tool', + name: 'get_weather', + }); + assert.strictEqual(body.tools?.length, 1); + + const streamingBody = runner.toAnthropicStreamingRequestBody( + 'claude-3-5-haiku', + request, + true + ); + assert.strictEqual(streamingBody.stream, true); + assert.ok(Array.isArray(streamingBody.system)); + }); + + it('should fall back to unknown tool name when metadata missing', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const exposed = runner as any; + + const part = exposed.fromBetaContentBlock({ + type: 'mcp_tool_use', + id: 'toolu_unknown', + input: {}, + }); + + assert.deepStrictEqual(part, { + toolRequest: { + ref: 'toolu_unknown', + name: 'unknown_tool', + input: {}, + }, + }); + }); + + it('should convert additional beta content block types', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + + const thinkingPart = (runner as any).fromBetaContentBlock({ + type: 'thinking', + thinking: 'pondering', + }); + assert.deepStrictEqual(thinkingPart, { text: 'pondering' }); + + const redactedPart = (runner as any).fromBetaContentBlock({ + type: 'redacted_thinking', + data: '[redacted]', + }); + assert.deepStrictEqual(redactedPart, { text: '[redacted]' }); + + const toolPart = (runner as any).fromBetaContentBlock({ + type: 'tool_use', + id: 'toolu_x', + name: 'plainTool', + input: { value: 1 }, + }); + assert.deepStrictEqual(toolPart, { + toolRequest: { + ref: 'toolu_x', + name: 'plainTool', + input: { value: 1 }, + }, + }); + + const warnMock = mock.method(console, 'warn', () => {}); + const fallbackPart = (runner as any).fromBetaContentBlock({ + type: 'mystery', + }); + assert.deepStrictEqual(fallbackPart, { text: '' }); + assert.strictEqual(warnMock.mock.calls.length, 1); + warnMock.mock.restore(); + }); + + it('should map additional stop reasons', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const exposed = runner as any; + + const refusal = exposed.fromBetaStopReason('refusal'); + assert.strictEqual(refusal, 'other'); + + const unknown = exposed.fromBetaStopReason('something-new'); + assert.strictEqual(unknown, 'other'); + + const nullReason = exposed.fromBetaStopReason(null); + assert.strictEqual(nullReason, 'unknown'); + }); +}); diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index 1272360c07..15b8d15aad 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -181,3 +181,56 @@ describe('Anthropic Plugin', () => { ); }); }); + +describe('Anthropic resolve helpers', () => { + it('should resolve model names without anthropic/ prefix', () => { + const mockClient = createMockAnthropicClient(); + const plugin = anthropic({ [__testClient]: mockClient } as PluginOptions); + + const action = plugin.resolve?.('model', 'claude-3-5-haiku'); + assert.ok(action, 'Should resolve model without prefix'); + assert.strictEqual(typeof action, 'function'); + }); + + it('anthropic.model should return namespaced reference with config', () => { + const reference = anthropic.model('claude-3-5-haiku', { + temperature: 0.25, + }); + + const referenceAny = reference as any; + assert.ok(referenceAny, 'Model reference should be created'); + assert.strictEqual(referenceAny.namespace, 'anthropic'); + assert.ok(referenceAny.name.includes('claude-3-5-haiku')); + assert.strictEqual(referenceAny.config?.temperature, 0.25); + }); + + it('should apply system prompt caching when cacheSystemPrompt is true', async () => { + const mockClient = createMockAnthropicClient(); + const plugin = anthropic({ + cacheSystemPrompt: true, + [__testClient]: mockClient, + } as PluginOptions); + + const action = plugin.resolve?.('model', 'anthropic/claude-3-5-haiku'); + assert.ok(action, 'Action should be resolved'); + + const abortSignal = new AbortController().signal; + await (action as any)( + { + messages: [ + { + role: 'system', + content: [{ text: 'You are helpful.' }], + }, + ], + }, + { streamingRequested: false, sendChunk: () => {}, abortSignal } + ); + + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + const requestBody = createStub.mock.calls[0].arguments[0]; + assert.ok(Array.isArray(requestBody.system)); + assert.strictEqual(requestBody.system[0].cache_control.type, 'ephemeral'); + }); +}); diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index f9710f2b3f..97554c1c92 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -263,4 +263,34 @@ describe('Anthropic Integration', () => { assert.strictEqual(result.usage.inputTokens, 25); assert.strictEqual(result.usage.outputTokens, 50); }); + + it('should route requests through beta surface when plugin default is beta', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [ + anthropic({ + apiVersion: 'beta', + [__testClient]: mockClient, + } as PluginOptions), + ], + }); + + await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'Hello', + }); + + const betaCreateStub = mockClient.beta.messages.create as any; + assert.strictEqual( + betaCreateStub.mock.calls.length, + 1, + 'Beta API should be used' + ); + const regularCreateStub = mockClient.messages.create as any; + assert.strictEqual( + regularCreateStub.mock.calls.length, + 0, + 'Stable API should not be used' + ); + }); }); diff --git a/js/plugins/anthropic/tests/mocks/README.md b/js/plugins/anthropic/tests/mocks/README.md deleted file mode 100644 index 4fd5554115..0000000000 --- a/js/plugins/anthropic/tests/mocks/README.md +++ /dev/null @@ -1,207 +0,0 @@ -/** - * 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. - */ - -# Anthropic Plugin Test Mocks - -Mock utilities for testing the Anthropic plugin without making real API calls. - -## Quick Start - -```typescript -import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; - -// Set up SDK mocking (call at module level, before describe blocks) -setupAnthropicMock(); - -describe('My Test Suite', () => { - // Your tests here -}); -``` - -## setupAnthropicMock() - -Sets up mocking for the Anthropic SDK module. Must be called at the module level before any describe blocks. - -### Basic Usage - -```typescript -setupAnthropicMock(); -``` - -### Custom Response - -```typescript -setupAnthropicMock({ - messageResponse: { - content: [{ type: 'text', text: 'Custom response' }] - } -}); -``` - -### Return Value - -Returns `{ mockClient, mockResponse }` for making assertions in tests. - -```typescript -const { mockClient } = setupAnthropicMock(); - -// Later in tests, verify SDK was called -assert.ok(mockClient.messages.create.mock.calls.length > 0); -``` - -## createMockAnthropicMessage() - -Creates customizable mock Anthropic Message responses for more complex test scenarios. - -### Text Response - -```typescript -import { createMockAnthropicMessage } from './mocks/anthropic-client.js'; - -const message = createMockAnthropicMessage({ - text: 'Hello from test' -}); -``` - -### Tool Use Response - -```typescript -const message = createMockAnthropicMessage({ - toolUse: { - name: 'get_weather', - input: { city: 'NYC' } - } -}); -``` - -### Custom Tokens - -```typescript -const message = createMockAnthropicMessage({ - usage: { - input_tokens: 100, - output_tokens: 500 - } -}); -``` - -### All Options - -```typescript -const message = createMockAnthropicMessage({ - id: 'msg_custom', // Default: 'msg_test123' - text: 'Custom text', // Default: 'Hello! How can I help you today?' - toolUse: { // Mutually exclusive with text - id: 'tool_123', // Default: 'toolu_test123' - name: 'tool_name', // Required - input: {} // Required - }, - stopReason: 'end_turn', // Default: 'end_turn' or 'tool_use' - usage: { - input_tokens: 10, - output_tokens: 20, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0 - } -}); -``` - -## Other Helpers - -### createMockAnthropicClient() - -Creates a mock Anthropic client with configurable responses for more complex test scenarios. - -```typescript -import { createMockAnthropicClient } from './mocks/anthropic-client.js'; - -const mockClient = createMockAnthropicClient({ - messageResponse: { content: [{ type: 'text', text: 'Response' }] }, - streamChunks: [mockTextChunk('chunk1'), mockTextChunk('chunk2')], - shouldError: new Error('API Error') -}); -``` - -### Stream Event Helpers - -```typescript -import { - mockTextChunk, - mockContentBlockStart, - mockToolUseChunk -} from './mocks/anthropic-client.js'; - -// Text delta for streaming -const chunk = mockTextChunk('Hello'); - -// Content block start -const start = mockContentBlockStart('Starting text'); - -// Tool use event -const toolChunk = mockToolUseChunk('tool_id', 'tool_name', { arg: 'value' }); -``` - -## Pattern: Testing Plugin Lifecycle - -```typescript -import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; - -setupAnthropicMock(); - -describe('Plugin Tests', () => { - let anthropic: any; - - before(async () => { - const { anthropic: plugin } = await import('../src/index.js'); - anthropic = plugin; - }); - - it('should initialize correctly', () => { - // Test plugin initialization - }); -}); -``` - -## Pattern: Testing Integration - -```typescript -import { genkit } from 'genkit'; -import { setupAnthropicMock } from './mocks/setup-anthropic-mock.js'; - -setupAnthropicMock({ - messageResponse: { - content: [{ type: 'text', text: 'Test response' }] - } -}); - -describe('Integration Tests', () => { - let ai: Genkit; - - before(async () => { - const { anthropic } = await import('../src/index.js'); - ai = genkit({ plugins: [anthropic()] }); - }); - - it('should generate responses', async () => { - const result = await ai.generate({ - model: 'anthropic/claude-3-5-sonnet', - prompt: 'Test' - }); - - assert.strictEqual(result.text, 'Test response'); - }); -}); -``` diff --git a/js/plugins/anthropic/tests/runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts similarity index 80% rename from js/plugins/anthropic/tests/runner_test.ts rename to js/plugins/anthropic/tests/stable_runner_test.ts index 72aa81a7bf..6165737ca2 100644 --- a/js/plugins/anthropic/tests/runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -33,13 +33,12 @@ import type { CandidateData, ToolDefinition } from 'genkit/model'; import { describe, it, mock } from 'node:test'; import { claudeModel, claudeRunner } from '../src/models.js'; -import { Runner } from '../src/runner/runner.js'; +import { Runner } from '../src/runner/stable.js'; import type { AnthropicConfigSchema } from '../src/types.js'; import { createMockAnthropicClient, mockContentBlockStart, mockTextChunk, - mockToolUseChunk, } from './mocks/anthropic-client.js'; // Test helper: Create a Runner instance for testing converter methods @@ -1101,7 +1100,10 @@ describe('claudeRunner', () => { }, }); - const runner = claudeRunner('claude-3-5-haiku', mockClient); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const abortSignal = new AbortController().signal; await runner( { messages: [] }, @@ -1146,7 +1148,10 @@ describe('claudeRunner', () => { }); const streamingCallback = mock.fn(); - const runner = claudeRunner('claude-3-5-haiku', mockClient); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const abortSignal = new AbortController().signal; await runner( { messages: [] }, @@ -1181,7 +1186,10 @@ describe('claudeRunner', () => { }, }); - const runner = claudeRunner('claude-3-5-haiku', mockClient); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const abortSignal = new AbortController().signal; await runner( { @@ -1211,12 +1219,11 @@ describe('claudeRunner', () => { }, }); - const runner = claudeRunner( - 'claude-3-5-haiku', - mockClient, - undefined, - 'beta' - ); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'beta', + }); const abortSignal = new AbortController().signal; await runner( { @@ -1305,7 +1312,113 @@ describe('claudeRunner', () => { }); }); +describe('claudeRunner param object', () => { + it('should run requests when constructed with params object', async () => { + const mockClient = createMockAnthropicClient(); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + cacheSystemPrompt: true, + }); + const abortSignal = new AbortController().signal; + + await runner( + { messages: [{ role: 'user', content: [{ text: 'hi' }] }] }, + { streamingRequested: false, sendChunk: () => {}, abortSignal } + ); + + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + assert.strictEqual( + createStub.mock.calls[0].arguments[0].messages[0].content[0].text, + 'hi' + ); + }); + + it('should route to beta runner when defaultApiVersion is beta', async () => { + const mockClient = createMockAnthropicClient(); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'beta', + }); + await runner( + { messages: [] }, + { + streamingRequested: false, + sendChunk: () => {}, + abortSignal: new AbortController().signal, + } + ); + + const betaCreateStub = mockClient.beta.messages.create as any; + assert.strictEqual(betaCreateStub.mock.calls.length, 1); + }); + + it('should throw when client is omitted from params object', () => { + assert.throws(() => { + claudeRunner('claude-3-5-haiku', undefined as unknown as Anthropic); + }, /Anthropic client is required to create a runner/); + }); +}); + describe('claudeModel', () => { + it('should fall back to generic metadata for unknown models', async () => { + const mockClient = createMockAnthropicClient(); + const modelAction = claudeModel({ + name: 'unknown-model', + client: mockClient, + }); + + const abortSignal = new AbortController().signal; + await (modelAction as any)( + { messages: [{ role: 'user', content: [{ text: 'hi' }] }] }, + { + streamingRequested: false, + sendChunk: () => {}, + abortSignal, + } + ); + + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + const request = createStub.mock.calls[0].arguments[0]; + assert.strictEqual(request.model, 'unknown-model'); + }); + it('should support params object configuration', async () => { + const mockClient = createMockAnthropicClient(); + const modelAction = claudeModel({ + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'beta', + cacheSystemPrompt: true, + }); + + const abortSignal = new AbortController().signal; + await (modelAction as any)( + { messages: [], config: { maxOutputTokens: 128 } }, + { + streamingRequested: false, + sendChunk: () => {}, + abortSignal, + } + ); + + const betaCreateStub = mockClient.beta.messages.create as any; + assert.strictEqual(betaCreateStub.mock.calls.length, 1); + assert.strictEqual( + betaCreateStub.mock.calls[0].arguments[0].max_tokens, + 128 + ); + }); + + it('should throw when client is omitted in params object', () => { + assert.throws( + () => claudeModel('claude-3-5-haiku'), + /Anthropic client is required to create a model action/ + ); + }); + it('should correctly define supported Claude models', () => { const mockClient = createMockAnthropicClient(); const modelName = 'claude-3-5-haiku'; @@ -1370,10 +1483,20 @@ describe('claudeModel', () => { }); it('should handle tool use in streaming mode', async () => { + const streamChunks = [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_123', + name: 'get_weather', + input: { city: 'NYC' }, + }, + } as MessageStreamEvent, + ]; const mockClient = createMockAnthropicClient({ - streamChunks: [ - mockToolUseChunk('toolu_123', 'get_weather', { city: 'NYC' }), - ], + streamChunks, messageResponse: { content: [ { @@ -1536,4 +1659,216 @@ describe('claudeModel', () => { } ); }); + + it('should handle unknown models using generic settings', async () => { + const mockClient = createMockAnthropicClient(); + const modelAction = claudeModel({ + name: 'unknown-model', + client: mockClient, + }); + + const abortSignal = new AbortController().signal; + await (modelAction as any)( + { messages: [{ role: 'user', content: [{ text: 'hi' }] }] }, + { + streamingRequested: false, + sendChunk: () => {}, + abortSignal, + } + ); + + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + assert.strictEqual( + createStub.mock.calls[0].arguments[0].model, + 'unknown-model' + ); + }); +}); + +describe('BaseRunner helper utilities', () => { + it('should throw descriptive errors for invalid PDF data URLs', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner('claude-3-5-haiku', mockClient); + + assert.throws( + () => + runner['toPdfDocumentSource']({ + url: 'data:text/plain;base64,AAA', + contentType: 'application/pdf', + } as any), + /PDF contentType mismatch/ + ); + }); + + it('should stringify non-media tool responses', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner('claude-3-5-haiku', mockClient); + + const result = runner['toAnthropicToolResponseContent']({ + toolResponse: { + ref: 'call_1', + name: 'tool', + output: { value: 42 }, + }, + } as any); + + assert.deepStrictEqual(result, { + type: 'text', + text: JSON.stringify({ value: 42 }), + }); + }); + + it('should parse image data URLs', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner('claude-3-5-haiku', mockClient); + + const source = runner['toImageSource']({ + url: 'data:image/png;base64,AAA', + contentType: 'image/png', + }); + + assert.strictEqual(source.mediaType, 'image/png'); + assert.strictEqual(source.data, 'AAA'); + }); +}); + +describe('Runner request bodies and error branches', () => { + it('should include optional config fields in non-streaming request body', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner('claude-3-5-haiku', mockClient, true) as Runner & + RunnerProtectedMethods; + + const body = runner['toAnthropicRequestBody']( + 'claude-3-5-haiku', + { + messages: [ + { + role: 'system', + content: [{ text: 'You are helpful.' }], + }, + { + role: 'user', + content: [{ text: 'Tell me a joke' }], + }, + ], + config: { + maxOutputTokens: 256, + topK: 3, + topP: 0.75, + temperature: 0.6, + stopSequences: ['END'], + metadata: { user_id: 'user-xyz' }, + tool_choice: { type: 'auto' }, + }, + tools: [ + { + name: 'get_weather', + description: 'Returns the weather', + inputSchema: { type: 'object' }, + }, + ], + } as unknown as GenerateRequest, + true + ); + + assert.strictEqual(body.model, 'claude-3-5-haiku-latest'); + assert.ok(Array.isArray(body.system)); + assert.strictEqual(body.system?.[0].cache_control?.type, 'ephemeral'); + assert.strictEqual(body.max_tokens, 256); + assert.strictEqual(body.top_k, 3); + assert.strictEqual(body.top_p, 0.75); + assert.strictEqual(body.temperature, 0.6); + assert.deepStrictEqual(body.stop_sequences, ['END']); + assert.deepStrictEqual(body.metadata, { user_id: 'user-xyz' }); + assert.deepStrictEqual(body.tool_choice, { type: 'auto' }); + assert.strictEqual(body.tools?.length, 1); + }); + + it('should include optional config fields in streaming request body', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner('claude-3-5-haiku', mockClient, true) as Runner & + RunnerProtectedMethods; + + const body = runner['toAnthropicStreamingRequestBody']( + 'claude-3-5-haiku', + { + messages: [ + { + role: 'system', + content: [{ text: 'Stay brief.' }], + }, + { + role: 'user', + content: [{ text: 'Summarize the weather.' }], + }, + ], + config: { + maxOutputTokens: 64, + topK: 2, + topP: 0.6, + temperature: 0.4, + stopSequences: ['STOP'], + metadata: { user_id: 'user-abc' }, + tool_choice: { type: 'any' }, + }, + tools: [ + { + name: 'summarize_weather', + description: 'Summarizes a forecast', + inputSchema: { type: 'object' }, + }, + ], + } as unknown as GenerateRequest, + true + ); + + assert.strictEqual(body.stream, true); + assert.ok(Array.isArray(body.system)); + assert.strictEqual(body.max_tokens, 64); + assert.strictEqual(body.top_k, 2); + assert.strictEqual(body.top_p, 0.6); + assert.strictEqual(body.temperature, 0.4); + assert.deepStrictEqual(body.stop_sequences, ['STOP']); + assert.deepStrictEqual(body.metadata, { user_id: 'user-abc' }); + assert.deepStrictEqual(body.tool_choice, { type: 'any' }); + assert.strictEqual(body.tools?.length, 1); + }); + + it('should throw descriptive errors for missing tool refs', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner('claude-3-5-haiku', mockClient, false) as Runner & + RunnerProtectedMethods; + + assert.throws( + () => + runner['toAnthropicMessageContent']({ + toolRequest: { + name: 'get_weather', + input: {}, + }, + } as any), + /Tool request ref is required/ + ); + + assert.throws( + () => + runner['toAnthropicMessageContent']({ + toolResponse: { + ref: undefined, + name: 'get_weather', + output: 'Sunny', + }, + } as any), + /Tool response ref is required/ + ); + + assert.throws( + () => + runner['toAnthropicMessageContent']({ + data: 'unexpected', + } as any), + /Unsupported genkit part fields/ + ); + }); }); From 51356644bcd0879bef55af6c970033a0e1a43b91 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 10:27:26 +0000 Subject: [PATCH 23/51] docs: refresh anthropic plugin branding and attribution --- js/plugins/anthropic/NOTICE | 8 ++++++ js/plugins/anthropic/README.md | 35 +++++++++++------------ js/plugins/anthropic/src/index.ts | 3 +- js/plugins/anthropic/src/models.ts | 3 +- js/plugins/anthropic/src/runner/base.ts | 3 +- js/plugins/anthropic/src/runner/beta.ts | 3 +- js/plugins/anthropic/src/runner/index.ts | 3 +- js/plugins/anthropic/src/runner/stable.ts | 3 +- js/plugins/anthropic/src/runner/types.ts | 3 +- js/plugins/anthropic/src/types.ts | 3 +- 10 files changed, 41 insertions(+), 26 deletions(-) create mode 100644 js/plugins/anthropic/NOTICE diff --git a/js/plugins/anthropic/NOTICE b/js/plugins/anthropic/NOTICE new file mode 100644 index 0000000000..9222b48dfe --- /dev/null +++ b/js/plugins/anthropic/NOTICE @@ -0,0 +1,8 @@ +This project includes code derived from the Firebase Genkit Anthropic community plugin +(https://github.com/BloomLabsInc/genkit-plugins/tree/main/plugins/anthropic). + +Original work Copyright 2024 Bloom Labs Inc. +Modifications Copyright 2025 Google LLC. + +Licensed under the Apache License, Version 2.0. +See the LICENSE file distributed with this project for the full license text. diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 4cf27eae0d..e3621424db 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -2,11 +2,11 @@

Firebase Genkit <> Anthropic AI Plugin

-

Anthropic AI Community Plugin for Google Firebase Genkit

+

Anthropic AI plugin for Google Firebase Genkit

Github lerna version - NPM Downloads + NPM Downloads GitHub Org's stars GitHub License Static Badge @@ -18,24 +18,19 @@ GitHub commit activity
-`genkitx-anthropic` is a community plugin for using Anthropic AI and all its supported models with [Firebase Genkit](https://github.com/firebase/genkit). - -This Genkit plugin allows to use Anthropic AI models through their official APIs. - -If you want to use Anthropic AI models through Google Vertex AI, please refer -to the [official Vertex AI plugin](https://www.npmjs.com/package/@genkit-ai/vertexai). +`@genkit-ai/anthropic` is the official Anthropic plugin for [Firebase Genkit](https://github.com/firebase/genkit). It supersedes the earlier community package `genkitx-anthropic` and is now maintained by Google. ## Supported models -The plugin supports the most recent Anthropic models: -**Claude 3.7 Sonnet**, **Claude 3.5 Sonnet**, **Claude 3 Opus**, **Claude 3 Sonnet**, and **Claude 3 Haiku**. +The plugin supports the most recent Anthropic models: **Claude 3.7 Sonnet**, **Claude 3.5 Sonnet**, **Claude 3 Opus**, **Claude 3 Sonnet**, and **Claude 3 Haiku**. ## Installation Install the plugin in your project with your favorite package manager: -- `npm install genkitx-anthropic` -- `yarn add genkitx-anthropic` +- `npm install @genkit-ai/anthropic` +- `yarn add @genkit-ai/anthropic` +- `pnpm add @genkit-ai/anthropic` ## Usage @@ -43,7 +38,7 @@ Install the plugin in your project with your favorite package manager: ```typescript import { genkit } from 'genkit'; -import { anthropic, claude35Sonnet } from 'genkitx-anthropic'; +import { anthropic, claude35Sonnet } from '@genkit-ai/anthropic'; const ai = genkit({ plugins: [anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })], @@ -58,7 +53,7 @@ The simplest way to generate text is by using the `generate` method: ```typescript const response = await ai.generate({ - model: claude3Haiku, // model imported from genkitx-anthropic + model: claude3Haiku, // model imported from @genkit-ai/anthropic prompt: 'Tell me a joke.', }); @@ -68,7 +63,7 @@ console.log(response.text); ### Multi-modal prompt ```typescript -// ...intialize Genkit instance (as shown above)... +// ...initialize Genkit instance (as shown above)... const response = await ai.generate({ prompt: [ @@ -106,20 +101,24 @@ export const jokeFlow = ai.defineFlow( ); ``` +## Acknowledgements + +This plugin builds on the community work published as [`genkitx-anthropic`](https://github.com/BloomLabsInc/genkit-plugins/blob/main/plugins/anthropic/README.md) by Bloom Labs Inc. Their Apache 2.0–licensed implementation provided the foundation for this maintained package. + ## Contributing Want to contribute to the project? That's awesome! Head over to our [Contribution Guidelines](CONTRIBUTING.md). ## Need support? -> \[!NOTE\]\ +> [!NOTE] > This repository depends on Google's Firebase Genkit. For issues and questions related to Genkit, please refer to instructions available in [Genkit's repository](https://github.com/firebase/genkit). -Reach out by opening a discussion on [Github Discussions](https://github.com/BloomLabsInc/genkitx-openai/discussions). +Reach out by opening a discussion on [GitHub Discussions](https://github.com/BloomLabsInc/genkitx-openai/discussions). ## Credits -This plugin is proudly maintained by the team at [**Bloom Labs Inc**](https://github.com/BloomLabsInc). 🔥 +This plugin is maintained by Google with acknowledgement to the community contributions from [Bloom Labs Inc](https://github.com/BloomLabsInc). ## License diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 0227eccb70..dba400ab9a 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index df37747a97..b233d57240 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index a41e3fbc53..3d8fd88bd8 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 22a84c4064..87e402033c 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. diff --git a/js/plugins/anthropic/src/runner/index.ts b/js/plugins/anthropic/src/runner/index.ts index ce7e3c6fdd..9be1981b3c 100644 --- a/js/plugins/anthropic/src/runner/index.ts +++ b/js/plugins/anthropic/src/runner/index.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 46d2dd6260..c6a7c59c51 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. diff --git a/js/plugins/anthropic/src/runner/types.ts b/js/plugins/anthropic/src/runner/types.ts index 59ed610647..75094d4ca1 100644 --- a/js/plugins/anthropic/src/runner/types.ts +++ b/js/plugins/anthropic/src/runner/types.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 4c503a3b6c..898aa7a348 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -1,5 +1,6 @@ /** - * Copyright 2025 Google LLC + * Original work Copyright 2024 Bloom Labs Inc + * Modifications 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. From 7812c8ac3b77a910ccb5d2af4cf9d2c6f5c08dbd Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 11:01:26 +0000 Subject: [PATCH 24/51] refactor(anthropic): add basic testapp and remove nested ternary --- js/plugins/anthropic/src/models.ts | 2 +- js/plugins/anthropic/src/runner/beta.ts | 26 ++-- .../anthropic/tests/beta_runner_test.ts | 113 +++++++++++++++++- js/pnpm-lock.yaml | 36 +++++- js/testapps/anthropic-basic/README.md | 23 ++++ js/testapps/anthropic-basic/package.json | 29 +++++ js/testapps/anthropic-basic/src/beta.ts | 64 ++++++++++ js/testapps/anthropic-basic/src/stable.ts | 51 ++++++++ js/testapps/anthropic-basic/tsconfig.json | 14 +++ 9 files changed, 338 insertions(+), 20 deletions(-) create mode 100644 js/testapps/anthropic-basic/README.md create mode 100644 js/testapps/anthropic-basic/package.json create mode 100644 js/testapps/anthropic-basic/src/beta.ts create mode 100644 js/testapps/anthropic-basic/src/stable.ts create mode 100644 js/testapps/anthropic-basic/tsconfig.json diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index b233d57240..969540565c 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -176,7 +176,7 @@ export const KNOWN_CLAUDE_MODELS: Record< /** * Generic Claude model info for unknown/unsupported models. - * Used when a model name is not in SUPPORTED_CLAUDE_MODELS. + * Used when a model name is not in KNOWN_CLAUDE_MODELS. */ const GENERIC_CLAUDE_MODEL_INFO = { versions: [], diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 87e402033c..eab2918f99 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -184,19 +184,19 @@ export class BetaRunner extends BaseRunner { const mappedModelName = request.config?.version ?? model?.version ?? modelName; - // Convert system: either raw string or cached text block array - const betaSystem = - system === undefined - ? undefined - : cacheSystemPrompt - ? [ - { - type: 'text' as const, - text: system, - cache_control: { type: 'ephemeral' as const }, - }, - ] - : system; + let betaSystem: BetaMessageCreateParamsNonStreaming['system']; + + if (system !== undefined) { + betaSystem = cacheSystemPrompt + ? [ + { + type: 'text' as const, + text: system, + cache_control: { type: 'ephemeral' as const }, + }, + ] + : system; + } const body: BetaMessageCreateParamsNonStreaming = { model: mappedModelName, diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 77ee2de9d0..32a01f5801 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -14,13 +14,122 @@ * limitations under the License. */ -import { Anthropic } from '@anthropic-ai/sdk'; import * as assert from 'assert'; -import { describe, it, mock } from 'node:test'; +import type { Part } from 'genkit'; +import { describe, it } from 'node:test'; import { BetaRunner } from '../src/runner/beta.js'; import { createMockAnthropicClient } from './mocks/anthropic-client.js'; +describe('BetaRunner.toAnthropicMessageContent', () => { + function createRunner() { + return new BetaRunner( + 'anthropic/claude-3-5-haiku', + createMockAnthropicClient(), + false + ); + } + + it('converts PDF media parts into document blocks', () => { + const runner = createRunner(); + const part: Part = { + media: { + contentType: 'application/pdf', + url: 'data:application/pdf;base64,UEsDBAoAAAAAAD', + }, + }; + + const result = (runner as any).toAnthropicMessageContent(part); + + assert.strictEqual(result.type, 'document'); + assert.ok(result.source); + assert.strictEqual(result.source.type, 'base64'); + assert.strictEqual(result.source.media_type, 'application/pdf'); + assert.ok(result.source.data); + }); + + it('throws when tool request ref is missing', () => { + const runner = createRunner(); + const part: Part = { + toolRequest: { + name: 'do_something', + input: { foo: 'bar' }, + }, + }; + + assert.throws(() => { + (runner as any).toAnthropicMessageContent(part); + }, /Tool request ref is required/); + }); + + it('maps tool request with ref into tool_use block', () => { + const runner = createRunner(); + const part: Part = { + toolRequest: { + ref: 'tool-123', + name: 'do_something', + input: { foo: 'bar' }, + }, + }; + + const result = (runner as any).toAnthropicMessageContent(part); + + assert.strictEqual(result.type, 'tool_use'); + assert.strictEqual(result.id, 'tool-123'); + assert.strictEqual(result.name, 'do_something'); + assert.deepStrictEqual(result.input, { foo: 'bar' }); + }); + + it('throws when tool response ref is missing', () => { + const runner = createRunner(); + const part: Part = { + toolResponse: { + name: 'do_something', + output: 'done', + }, + }; + + assert.throws(() => { + (runner as any).toAnthropicMessageContent(part); + }, /Tool response ref is required/); + }); + + it('maps tool response into tool_result block containing text response', () => { + const runner = createRunner(); + const part: Part = { + toolResponse: { + name: 'do_something', + ref: 'tool-abc', + output: 'done', + }, + }; + + const result = (runner as any).toAnthropicMessageContent(part); + + assert.strictEqual(result.type, 'tool_result'); + assert.strictEqual(result.tool_use_id, 'tool-abc'); + assert.deepStrictEqual(result.content, [{ type: 'text', text: 'done' }]); + }); +}); +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Anthropic } from '@anthropic-ai/sdk'; +import { mock } from 'node:test'; + describe('BetaRunner', () => { it('should map all supported Part shapes to beta content blocks', () => { const mockClient = createMockAnthropicClient(); diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index e80faab0ce..f6e6a17870 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -153,10 +153,6 @@ importers: zod-to-json-schema: specifier: ^3.22.4 version: 3.24.5(zod@3.25.67) - optionalDependencies: - '@genkit-ai/firebase': - specifier: ^1.16.1 - version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) devDependencies: '@types/express': specifier: ^4.17.21 @@ -183,12 +179,18 @@ importers: specifier: ^4.9.0 version: 4.9.5 <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> 2b79f7091 (refactor(anthropic): add basic testapp and remove nested ternary) optionalDependencies: '@genkit-ai/firebase': specifier: ^1.16.1 version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) +<<<<<<< HEAD ======= >>>>>>> 6f4fc016b (refactor(anthropic): a lot of refactoring and add beta api) +======= +>>>>>>> 2b79f7091 (refactor(anthropic): add basic testapp and remove nested ternary) doc-snippets: dependencies: @@ -987,6 +989,7 @@ importers: version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67) ======= version: 4.104.0(encoding@0.1.13)(zod@3.25.67) +<<<<<<< HEAD optionalDependencies: '@google-cloud/bigquery': specifier: ^7.8.0 @@ -995,6 +998,8 @@ importers: specifier: '>=12.2' version: 13.4.0(encoding@0.1.13) >>>>>>> 5cbe620e1 (refactor(anthropic): a lot of refactoring and add beta api) +======= +>>>>>>> ead4341c2 (refactor(anthropic): add basic testapp and remove nested ternary) devDependencies: '@types/node': specifier: ^20.11.16 @@ -1026,6 +1031,29 @@ importers: typescript: specifier: ^4.9.0 version: 4.9.5 + optionalDependencies: + '@google-cloud/bigquery': + specifier: ^7.8.0 + version: 7.9.4(encoding@0.1.13) + firebase-admin: + specifier: '>=12.2' + version: 13.4.0(encoding@0.1.13) + + testapps/anthropic-basic: + dependencies: + '@genkit-ai/anthropic': + specifier: workspace:* + version: link:../../plugins/anthropic + genkit: + specifier: workspace:* + version: link:../../genkit + devDependencies: + tsx: + specifier: ^4.19.2 + version: 4.20.3 + typescript: + specifier: ^5.6.2 + version: 5.8.3 testapps/basic-gemini: dependencies: diff --git a/js/testapps/anthropic-basic/README.md b/js/testapps/anthropic-basic/README.md new file mode 100644 index 0000000000..8863e81321 --- /dev/null +++ b/js/testapps/anthropic-basic/README.md @@ -0,0 +1,23 @@ +# Anthropic Plugin Sample + +This test app demonstrates minimal usage of the Genkit Anthropic plugin against both the stable and beta runners. + +## Setup + +1. From the repo root run `pnpm install` followed by `pnpm run setup` to link workspace dependencies. +2. In this directory, optionally run `pnpm install` if you want a local `node_modules/`. +3. Export an Anthropic API key (or add it to a `.env` file) before running any samples: + + ```bash + export ANTHROPIC_API_KEY=your-key + ``` + +## Available scripts + +- `pnpm run build` – Compile the TypeScript sources into `lib/`. +- `pnpm run start:stable` – Run the compiled stable sample. +- `pnpm run start:beta` – Run the compiled beta sample. +- `pnpm run dev:stable` – Start the Genkit Dev UI over `src/stable.ts` with live reload. +- `pnpm run dev:beta` – Start the Genkit Dev UI over `src/beta.ts` with live reload. + +Each source file defines a couple of flows that can be invoked from the Dev UI or the Genkit CLI (for example, `genkit flow:run anthropic-stable-hello`). diff --git a/js/testapps/anthropic-basic/package.json b/js/testapps/anthropic-basic/package.json new file mode 100644 index 0000000000..ae437730da --- /dev/null +++ b/js/testapps/anthropic-basic/package.json @@ -0,0 +1,29 @@ +{ + "name": "anthropic-basic", + "version": "1.0.0", + "description": "Sample Genkit app showcasing Anthropic plugin stable and beta usage.", + "main": "lib/stable.js", + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "start:stable": "node lib/stable.js", + "start:beta": "node lib/beta.js", + "dev:stable": "genkit start -- npx tsx --watch src/stable.ts", + "dev:beta": "genkit start -- npx tsx --watch src/beta.ts" + }, + "keywords": [ + "genkit", + "anthropic", + "sample" + ], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "genkit": "workspace:*", + "@genkit-ai/anthropic": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.6.2", + "tsx": "^4.19.2" + } +} diff --git a/js/testapps/anthropic-basic/src/beta.ts b/js/testapps/anthropic-basic/src/beta.ts new file mode 100644 index 0000000000..2572b2852e --- /dev/null +++ b/js/testapps/anthropic-basic/src/beta.ts @@ -0,0 +1,64 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { anthropic } from '@genkit-ai/anthropic'; +import { genkit } from 'genkit'; + +const ai = genkit({ + plugins: [ + // Default all flows in this sample to the beta surface + anthropic({ apiVersion: 'beta', cacheSystemPrompt: true }), + ], +}); + +const betaHaiku = anthropic.model('claude-3-5-haiku', { apiVersion: 'beta' }); +const betaSonnet = anthropic.model('claude-4-5-sonnet', { apiVersion: 'beta' }); + +ai.defineFlow('anthropic-beta-hello', async () => { + const { text } = await ai.generate({ + model: betaHaiku, + prompt: + 'You are Claude on the beta API. Provide a concise greeting that mentions that you are using the beta API.', + config: { temperature: 0.6 }, + }); + + return text; +}); + +ai.defineFlow('anthropic-beta-stream', async (_, { sendChunk }) => { + const { stream } = ai.generateStream({ + model: betaSonnet, + prompt: [ + { + text: 'Outline two experimental capabilities unlocked by the Anthropic beta API.', + }, + ], + config: { + apiVersion: 'beta', + temperature: 0.4, + }, + }); + + const collected: string[] = []; + for await (const chunk of stream) { + if (chunk.text) { + collected.push(chunk.text); + sendChunk(chunk.text); + } + } + + return collected.join(''); +}); diff --git a/js/testapps/anthropic-basic/src/stable.ts b/js/testapps/anthropic-basic/src/stable.ts new file mode 100644 index 0000000000..5ca6236adb --- /dev/null +++ b/js/testapps/anthropic-basic/src/stable.ts @@ -0,0 +1,51 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { anthropic } from '@genkit-ai/anthropic'; +import { genkit } from 'genkit'; + +const ai = genkit({ + plugins: [ + // Configure the plugin with environment-driven API key + anthropic(), + ], +}); + +ai.defineFlow('anthropic-stable-hello', async () => { + const { text } = await ai.generate({ + model: anthropic.model('claude-3-5-haiku'), + prompt: 'You are a friendly Claude assistant. Greet the user briefly.', + }); + + return text; +}); + +ai.defineFlow('anthropic-stable-stream', async (_, { sendChunk }) => { + const { stream } = ai.generateStream({ + model: anthropic.model('claude-3-5-haiku'), + prompt: 'Compose a short limerick about using Genkit with Anthropic.', + }); + + let response = ''; + for await (const chunk of stream) { + response += chunk.text ?? ''; + if (chunk.text) { + sendChunk(chunk.text); + } + } + + return response; +}); diff --git a/js/testapps/anthropic-basic/tsconfig.json b/js/testapps/anthropic-basic/tsconfig.json new file mode 100644 index 0000000000..efbb566bf7 --- /dev/null +++ b/js/testapps/anthropic-basic/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compileOnSave": true, + "include": ["src"], + "compilerOptions": { + "module": "commonjs", + "noImplicitReturns": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017", + "skipLibCheck": true, + "esModuleInterop": true + } +} From 7e8aaf6afeafad51fe082b703ecf1c8244c318aa Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 11:13:21 +0000 Subject: [PATCH 25/51] refactor(anthropic): remove redundant factory layer --- js/plugins/anthropic/src/models.ts | 45 +++--------------- .../anthropic/tests/stable_runner_test.ts | 47 ++++++++++++------- 2 files changed, 36 insertions(+), 56 deletions(-) diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 969540565c..326c34cee8 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -197,52 +197,19 @@ export type ClaudeConfig = z.infer; /** * Creates the runner used by Genkit to interact with the Claude model. - * @param name The name of the Claude model. - * @param client The Anthropic client instance. - * @param cacheSystemPrompt Whether to cache the system prompt. - * @param defaultApiVersion Plugin-wide default API surface. + * @param params Configuration for the Claude runner. * @returns The runner that Genkit will call when the model is invoked. */ -export function claudeRunner( - params: ClaudeRunnerParams -): ReturnType; -export function claudeRunner( - name: string, - client: Anthropic, - cacheSystemPrompt?: boolean, - defaultApiVersion?: 'stable' | 'beta' -): ReturnType; -export function claudeRunner( - paramsOrName: ClaudeRunnerParams | string, - client?: Anthropic, - cacheSystemPrompt?: boolean, - defaultApiVersion?: 'stable' | 'beta' -) { - const params = - typeof paramsOrName === 'string' - ? { - name: paramsOrName, - client: - client ?? - (() => { - throw new Error( - 'Anthropic client is required to create a runner' - ); - })(), - cacheSystemPrompt, - defaultApiVersion, - } - : paramsOrName; - - return createClaudeRunner(params); -} - -function createClaudeRunner({ +export function claudeRunner({ name, client, cacheSystemPrompt, defaultApiVersion, }: ClaudeRunnerParams) { + if (!client) { + throw new Error('Anthropic client is required to create a runner'); + } + let stableRunner: Runner | null = null; let betaRunner: BetaRunner | null = null; diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 6165737ca2..cd90b9db90 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -1253,12 +1253,11 @@ describe('claudeRunner', () => { }); // defaultApiVersion is 'stable', but request overrides to 'beta' - const runner = claudeRunner( - 'claude-3-5-haiku', - mockClient, - undefined, - 'stable' - ); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'stable', + }); const abortSignal = new AbortController().signal; await runner( { @@ -1289,12 +1288,11 @@ describe('claudeRunner', () => { }); // defaultApiVersion is 'beta', but request overrides to 'stable' - const runner = claudeRunner( - 'claude-3-5-haiku', - mockClient, - undefined, - 'beta' - ); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'beta', + }); const abortSignal = new AbortController().signal; await runner( { @@ -1357,7 +1355,10 @@ describe('claudeRunner param object', () => { it('should throw when client is omitted from params object', () => { assert.throws(() => { - claudeRunner('claude-3-5-haiku', undefined as unknown as Anthropic); + claudeRunner({ + name: 'claude-3-5-haiku', + client: undefined as unknown as Anthropic, + }); }, /Anthropic client is required to create a runner/); }); }); @@ -1459,7 +1460,10 @@ describe('claudeModel', () => { chunks.push(chunk); }); - const runner = claudeRunner('claude-3-5-haiku', mockClient); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const abortSignal = new AbortController().signal; const result = await runner( @@ -1521,7 +1525,10 @@ describe('claudeModel', () => { chunks.push(chunk); }); - const runner = claudeRunner('claude-3-5-haiku', mockClient); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const abortSignal = new AbortController().signal; const result = await runner( @@ -1576,7 +1583,10 @@ describe('claudeModel', () => { }, }); - const runner = claudeRunner('claude-3-5-haiku', mockClient); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const abortSignal = new AbortController().signal; const chunks: any[] = []; const sendChunk = (chunk: any) => { @@ -1626,7 +1636,10 @@ describe('claudeModel', () => { }, }); - const runner = claudeRunner('claude-3-5-haiku', mockClient); + const runner = claudeRunner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const abortController = new AbortController(); const chunks: any[] = []; const sendChunk = (chunk: any) => { From 04a3c9eab3ac2a1e0685571908b3f50b95747ed0 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 11:45:40 +0000 Subject: [PATCH 26/51] refactor(anthropic): clean up ABC pattern and generic name --- js/plugins/anthropic/src/runner/base.ts | 158 +++--------------- js/plugins/anthropic/src/runner/stable.ts | 144 +++++++++++++++- .../anthropic/tests/stable_runner_test.ts | 39 +++++ 3 files changed, 203 insertions(+), 138 deletions(-) diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index 3d8fd88bd8..1c1bea9132 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -16,18 +16,12 @@ */ import { Anthropic } from '@anthropic-ai/sdk'; -import type { - ContentBlock, - DocumentBlockParam, - Message, - MessageStreamEvent, -} from '@anthropic-ai/sdk/resources/messages'; +import type { DocumentBlockParam } from '@anthropic-ai/sdk/resources/messages'; import type { GenerateRequest, GenerateResponseChunkData, GenerateResponseData, MessageData, - ModelResponseData, Part, Role, } from 'genkit'; @@ -55,7 +49,7 @@ import { RunnerTypes, } from './types.js'; -export abstract class BaseRunner { +export abstract class BaseRunner { protected name: string; protected client: Anthropic; protected cacheSystemPrompt?: boolean; @@ -182,7 +176,7 @@ export abstract class BaseRunner { */ protected toAnthropicToolResponseContent( part: Part - ): RunnerToolResponseContent { + ): RunnerToolResponseContent { const output = part.toolResponse?.output ?? {}; // Handle Media objects (images returned by tools) @@ -197,7 +191,7 @@ export abstract class BaseRunner { data, media_type: contentType, }, - } as RunnerToolResponseContent; + }; } } @@ -215,21 +209,21 @@ export abstract class BaseRunner { data, media_type: contentType, }, - } as RunnerToolResponseContent; + }; } } // Regular string output return { type: 'text', text: output, - } as RunnerToolResponseContent; + }; } // Handle other outputs by stringifying return { type: 'text', text: JSON.stringify(output), - } as RunnerToolResponseContent; + }; } /** @@ -238,7 +232,7 @@ export abstract class BaseRunner { */ protected abstract toAnthropicMessageContent( part: Part - ): RunnerContentBlockParam; + ): RunnerContentBlockParam; /** * Converts Genkit messages to Anthropic format. @@ -247,7 +241,7 @@ export abstract class BaseRunner { */ protected toAnthropicMessages(messages: MessageData[]): { system?: string; - messages: RunnerMessageParam[]; + messages: RunnerMessageParam[]; } { const system = messages[0]?.role === 'system' @@ -255,7 +249,7 @@ export abstract class BaseRunner { : undefined; const messagesToIterate = system ? messages.slice(1) : messages; - const anthropicMsgs: RunnerMessageParam[] = []; + const anthropicMsgs: RunnerMessageParam[] = []; for (const message of messagesToIterate) { const msg = new GenkitMessage(message); @@ -276,7 +270,7 @@ export abstract class BaseRunner { this.toAnthropicMessageContent(part) ); - anthropicMsgs.push({ role, content } as RunnerMessageParam); + anthropicMsgs.push({ role, content }); } return { system, messages: anthropicMsgs }; @@ -285,122 +279,12 @@ export abstract class BaseRunner { /** * Converts a Genkit ToolDefinition to an Anthropic Tool object. */ - protected toAnthropicTool(tool: ToolDefinition): RunnerTool { + protected toAnthropicTool(tool: ToolDefinition): RunnerTool { return { name: tool.name, description: tool.description, input_schema: tool.inputSchema, - } as RunnerTool; - } - - /** - * Converts an Anthropic content block to a Genkit Part object. - * @param contentBlock The Anthropic content block to convert. - * @returns The converted Genkit Part object. - * @param event The Anthropic message stream event to convert. - * @returns The converted Genkit Part object if the event is a content block - * start or delta, otherwise undefined. - */ - protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { - if (contentBlock.type === 'tool_use') { - return { - toolRequest: { - ref: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }, - }; - } else if (contentBlock.type === 'text') { - return { text: contentBlock.text }; - } else if (contentBlock.type === 'thinking') { - return { text: contentBlock.thinking }; - } else if (contentBlock.type === 'redacted_thinking') { - return { text: contentBlock.data }; - } else { - // Handle unexpected content block types - // Log warning for debugging, but return empty text to avoid breaking the flow - const unknownType = (contentBlock as { type: string }).type; - console.warn( - `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` - ); - return { text: '' }; - } - } - - /** - * Converts an Anthropic message stream event to a Genkit Part object. - */ - protected fromAnthropicContentBlockChunk( - event: MessageStreamEvent - ): Part | undefined { - if ( - event.type === 'content_block_delta' && - event.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 ( - event.type !== 'content_block_start' && - event.type !== 'content_block_delta' - ) { - return; - } - const eventField = - event.type === 'content_block_start' ? 'content_block' : 'delta'; - return ['text', 'text_delta'].includes(event[eventField].type) - ? { - text: event[eventField].text, - } - : { - toolRequest: { - ref: event[eventField].id, - name: event[eventField].name, - input: event[eventField].input, - }, - }; - } - - protected fromAnthropicStopReason( - reason: Message['stop_reason'] - ): ModelResponseData['finishReason'] { - switch (reason) { - case 'max_tokens': - return 'length'; - case 'end_turn': - // fall through - case 'stop_sequence': - // fall through - case 'tool_use': - return 'stop'; - case null: - return 'unknown'; - default: - 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, - }; + } as RunnerTool; } /** @@ -415,7 +299,7 @@ export abstract class BaseRunner { modelName: string, request: GenerateRequest, cacheSystemPrompt?: boolean - ): RunnerRequestBody; + ): RunnerRequestBody; /** * Converts an Anthropic request to a streaming Anthropic API request body. @@ -429,24 +313,24 @@ export abstract class BaseRunner { modelName: string, request: GenerateRequest, cacheSystemPrompt?: boolean - ): RunnerStreamingRequestBody; + ): RunnerStreamingRequestBody; protected abstract createMessage( - body: RunnerRequestBody, + body: RunnerRequestBody, abortSignal: AbortSignal - ): Promise>; + ): Promise>; protected abstract streamMessages( - body: RunnerStreamingRequestBody, + body: RunnerStreamingRequestBody, abortSignal: AbortSignal - ): RunnerStream; + ): RunnerStream; protected abstract toGenkitResponse( - message: RunnerMessage + message: RunnerMessage ): GenerateResponseData; protected abstract toGenkitPart( - event: RunnerStreamEvent + event: RunnerStreamEvent ): Part | undefined; public async run( diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index c6a7c59c51..4263369ddf 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -18,6 +18,7 @@ import { Anthropic } from '@anthropic-ai/sdk'; import { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream.js'; import type { + ContentBlock, DocumentBlockParam, ImageBlockParam, Message, @@ -30,7 +31,12 @@ import type { ToolResultBlockParam, ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/messages'; -import type { GenerateRequest, GenerateResponseData, Part } from 'genkit'; +import type { + GenerateRequest, + GenerateResponseData, + ModelResponseData, + Part, +} from 'genkit'; import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema } from '../types.js'; @@ -281,4 +287,140 @@ export class Runner extends BaseRunner { 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 { text: delta.thinking }; + } + + // signature_delta - ignore + return undefined; + } + + // Handle content_block_start events + if (event.type === 'content_block_start') { + const block = event.content_block; + + if (block.type === 'text') { + return { text: block.text }; + } + + if (block.type === 'thinking') { + return { text: block.thinking }; + } + + if (block.type === 'redacted_thinking') { + return { text: block.data }; + } + + if (block.type === 'tool_use') { + return { + toolRequest: { + ref: block.id, + name: block.name, + input: block.input, + }, + }; + } + + // server_tool_use, web_search_tool_result, or unknown types - treat as tool_use + if ('id' in block && 'name' in block) { + return { + toolRequest: { + ref: block.id, + name: block.name, + input: 'input' in block ? block.input : undefined, + }, + }; + } + } + + // Other event types (message_start, message_delta, etc.) - ignore + return undefined; + } + + protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { + switch (contentBlock.type) { + case 'tool_use': + return { + toolRequest: { + ref: contentBlock.id, + name: contentBlock.name, + input: contentBlock.input, + }, + }; + case 'text': + return { text: contentBlock.text }; + case 'thinking': + return { text: contentBlock.thinking }; + case 'redacted_thinking': + return { text: contentBlock.data }; + default: { + // Handle unexpected content block types + // Log warning for debugging, but return empty text to avoid breaking the flow + const unknownType = (contentBlock as { type: string }).type; + console.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'] { + switch (reason) { + case 'max_tokens': + return 'length'; + case 'end_turn': + // fall through + case 'stop_sequence': + // fall through + case 'tool_use': + return 'stop'; + case null: + return 'unknown'; + default: + 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/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index cd90b9db90..497d310c46 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -667,6 +667,33 @@ describe('fromAnthropicContentBlockChunk', () => { }, expectedOutput: { text: 'Hello, World!' }, }, + { + should: + 'should return thinking part from content_block_start thinking event', + event: { + index: 0, + type: 'content_block_start', + content_block: { + type: 'thinking', + thinking: 'Let me reason through this.', + signature: 'sig_123', + }, + }, + expectedOutput: { text: 'Let me reason through this.' }, + }, + { + should: + 'should return redacted thinking part from content_block_start event', + event: { + index: 0, + type: 'content_block_start', + content_block: { + type: 'redacted_thinking', + data: 'encrypted-data', + }, + }, + expectedOutput: { text: 'encrypted-data' }, + }, { should: 'should return text delta part from content_block_delta event', event: { @@ -679,6 +706,18 @@ describe('fromAnthropicContentBlockChunk', () => { }, expectedOutput: { text: 'Hello, World!' }, }, + { + should: 'should return thinking delta part as text content', + event: { + index: 0, + type: 'content_block_delta', + delta: { + type: 'thinking_delta', + thinking: 'Step by step...', + }, + }, + expectedOutput: { text: 'Step by step...' }, + }, { should: 'should return tool use requests', event: { From d6ed66b9f5f234c85e5427569fbad36975f8700e Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 11:59:26 +0000 Subject: [PATCH 27/51] refactor(anthropic): collect up runner param types --- js/plugins/anthropic/src/models.ts | 15 ++--- js/plugins/anthropic/src/runner/base.ts | 10 ++-- js/plugins/anthropic/src/runner/beta.ts | 7 +-- js/plugins/anthropic/src/runner/stable.ts | 7 +-- js/plugins/anthropic/src/runner/types.ts | 24 ++++---- .../anthropic/tests/beta_runner_test.ts | 60 +++++++++++++------ .../anthropic/tests/stable_runner_test.ts | 42 +++++++++---- 7 files changed, 103 insertions(+), 62 deletions(-) diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 326c34cee8..38491de089 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -200,13 +200,10 @@ export type ClaudeConfig = z.infer; * @param params Configuration for the Claude runner. * @returns The runner that Genkit will call when the model is invoked. */ -export function claudeRunner({ - name, - client, - cacheSystemPrompt, - defaultApiVersion, -}: ClaudeRunnerParams) { - if (!client) { +export function claudeRunner(params: ClaudeRunnerParams) { + const { defaultApiVersion, ...runnerParams } = params; + + if (!runnerParams.client) { throw new Error('Anthropic client is required to create a runner'); } @@ -227,8 +224,8 @@ export function claudeRunner({ ): Promise => { const isBeta = resolveBetaEnabled(request.config, defaultApiVersion); const runner = isBeta - ? (betaRunner ??= new BetaRunner(name, client, cacheSystemPrompt)) - : (stableRunner ??= new Runner(name, client, cacheSystemPrompt)); + ? (betaRunner ??= new BetaRunner(runnerParams)) + : (stableRunner ??= new Runner(runnerParams)); return runner.run(request, { streamingRequested, sendChunk, abortSignal }); }; } diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index 1c1bea9132..ee26ca6aae 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -34,6 +34,7 @@ import { MediaSchema, MediaType, MediaTypeSchema, + type ClaudeRunnerParams, } from '../types.js'; import { @@ -59,10 +60,10 @@ export abstract class BaseRunner { */ protected readonly DEFAULT_MAX_OUTPUT_TOKENS = 4096; - constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { - this.name = name; - this.client = client; - this.cacheSystemPrompt = cacheSystemPrompt; + constructor(params: ClaudeRunnerParams) { + this.name = params.name; + this.client = params.client; + this.cacheSystemPrompt = params.cacheSystemPrompt; } /** @@ -94,7 +95,6 @@ export abstract class BaseRunner { /** * Checks if a URL is a data URL (starts with 'data:'). - * This follows the Google GenAI plugin pattern for distinguishing inline data from file references. */ protected isDataUrl(url: string): boolean { return url.startsWith('data:'); diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index eab2918f99..d7793e200f 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { Anthropic } from '@anthropic-ai/sdk'; import { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream.js'; import type { BetaContentBlock, @@ -45,7 +44,7 @@ import type { } from 'genkit'; import { KNOWN_CLAUDE_MODELS } from '../models.js'; -import { AnthropicConfigSchema } from '../types.js'; +import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { BaseRunner } from './base.js'; type BetaToolUseLike = @@ -74,8 +73,8 @@ type BetaRunnerTypes = { * Runner for the Anthropic Beta API. */ export class BetaRunner extends BaseRunner { - constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { - super(name, client, cacheSystemPrompt); + constructor(params: ClaudeRunnerParams) { + super(params); } /** diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 4263369ddf..ce63e0524e 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { Anthropic } from '@anthropic-ai/sdk'; import { MessageStream } from '@anthropic-ai/sdk/lib/MessageStream.js'; import type { ContentBlock, @@ -39,7 +38,7 @@ import type { } from 'genkit'; import { KNOWN_CLAUDE_MODELS } from '../models.js'; -import { AnthropicConfigSchema } from '../types.js'; +import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { BaseRunner } from './base.js'; type RunnerTypes = { @@ -60,8 +59,8 @@ type RunnerTypes = { }; export class Runner extends BaseRunner { - constructor(name: string, client: Anthropic, cacheSystemPrompt?: boolean) { - super(name, client, cacheSystemPrompt); + constructor(params: ClaudeRunnerParams) { + super(params); } protected toAnthropicMessageContent( diff --git a/js/plugins/anthropic/src/runner/types.ts b/js/plugins/anthropic/src/runner/types.ts index 75094d4ca1..952018423b 100644 --- a/js/plugins/anthropic/src/runner/types.ts +++ b/js/plugins/anthropic/src/runner/types.ts @@ -30,17 +30,19 @@ type RunnerTypes = { ToolResponseContent: unknown; }; -type RunnerMessage = T['Message']; -type RunnerStream = T['Stream']; -type RunnerStreamEvent = T['StreamEvent']; -type RunnerRequestBody = T['RequestBody']; -type RunnerStreamingRequestBody = - T['StreamingRequestBody']; -type RunnerTool = T['Tool']; -type RunnerMessageParam = T['MessageParam']; -type RunnerContentBlockParam = T['ContentBlockParam']; -type RunnerToolResponseContent = - T['ToolResponseContent']; +type RunnerMessage = ApiTypes['Message']; +type RunnerStream = ApiTypes['Stream']; +type RunnerStreamEvent = ApiTypes['StreamEvent']; +type RunnerRequestBody = ApiTypes['RequestBody']; +type RunnerStreamingRequestBody = + ApiTypes['StreamingRequestBody']; +type RunnerTool = ApiTypes['Tool']; +type RunnerMessageParam = + ApiTypes['MessageParam']; +type RunnerContentBlockParam = + ApiTypes['ContentBlockParam']; +type RunnerToolResponseContent = + ApiTypes['ToolResponseContent']; export { RunnerContentBlockParam, diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 32a01f5801..46b8515350 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -23,11 +23,11 @@ import { createMockAnthropicClient } from './mocks/anthropic-client.js'; describe('BetaRunner.toAnthropicMessageContent', () => { function createRunner() { - return new BetaRunner( - 'anthropic/claude-3-5-haiku', - createMockAnthropicClient(), - false - ); + return new BetaRunner({ + name: 'anthropic/claude-3-5-haiku', + client: createMockAnthropicClient(), + cacheSystemPrompt: false, + }); } it('converts PDF media parts into document blocks', () => { @@ -133,7 +133,10 @@ import { mock } from 'node:test'; describe('BetaRunner', () => { it('should map all supported Part shapes to beta content blocks', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const exposed = runner as any; @@ -186,7 +189,10 @@ describe('BetaRunner', () => { it('should convert beta stream events to Genkit Parts', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const exposed = runner as any; const textPart = exposed.toGenkitPart({ @@ -229,7 +235,10 @@ describe('BetaRunner', () => { it('should map beta stop reasons correctly', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const finishReason = runner['fromBetaStopReason']( 'model_context_window_exceeded' @@ -254,7 +263,10 @@ describe('BetaRunner', () => { streamError, }); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const sendChunk = mock.fn(); await assert.rejects(async () => runner.run({ messages: [] } as any, { @@ -278,7 +290,10 @@ describe('BetaRunner', () => { it('should throw when tool refs are missing in message content', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const exposed = runner as any; assert.throws(() => @@ -311,11 +326,11 @@ describe('BetaRunner', () => { it('should build request bodies with optional config fields', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner( - 'claude-3-5-haiku', - mockClient as Anthropic, - true - ) as any; + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + cacheSystemPrompt: true, + }) as any; const request = { messages: [ @@ -377,7 +392,10 @@ describe('BetaRunner', () => { it('should fall back to unknown tool name when metadata missing', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const exposed = runner as any; const part = exposed.fromBetaContentBlock({ @@ -397,7 +415,10 @@ describe('BetaRunner', () => { it('should convert additional beta content block types', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const thinkingPart = (runner as any).fromBetaContentBlock({ type: 'thinking', @@ -436,7 +457,10 @@ describe('BetaRunner', () => { it('should map additional stop reasons', () => { const mockClient = createMockAnthropicClient(); - const runner = new BetaRunner('claude-test', mockClient as Anthropic); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); const exposed = runner as any; const refusal = exposed.fromBetaStopReason('refusal'); diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 497d310c46..d568db6d5c 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -73,8 +73,10 @@ type RunnerProtectedMethods = { }; const mockClient = createMockAnthropicClient(); -const testRunner = new Runner('test-model', mockClient) as Runner & - RunnerProtectedMethods; +const testRunner = new Runner({ + name: 'test-model', + client: mockClient, +}) as Runner & RunnerProtectedMethods; const createUsage = ( overrides: Partial = {} @@ -1741,7 +1743,10 @@ describe('claudeModel', () => { describe('BaseRunner helper utilities', () => { it('should throw descriptive errors for invalid PDF data URLs', () => { const mockClient = createMockAnthropicClient(); - const runner = new Runner('claude-3-5-haiku', mockClient); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); assert.throws( () => @@ -1755,7 +1760,10 @@ describe('BaseRunner helper utilities', () => { it('should stringify non-media tool responses', () => { const mockClient = createMockAnthropicClient(); - const runner = new Runner('claude-3-5-haiku', mockClient); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const result = runner['toAnthropicToolResponseContent']({ toolResponse: { @@ -1773,7 +1781,10 @@ describe('BaseRunner helper utilities', () => { it('should parse image data URLs', () => { const mockClient = createMockAnthropicClient(); - const runner = new Runner('claude-3-5-haiku', mockClient); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); const source = runner['toImageSource']({ url: 'data:image/png;base64,AAA', @@ -1788,8 +1799,11 @@ describe('BaseRunner helper utilities', () => { describe('Runner request bodies and error branches', () => { it('should include optional config fields in non-streaming request body', () => { const mockClient = createMockAnthropicClient(); - const runner = new Runner('claude-3-5-haiku', mockClient, true) as Runner & - RunnerProtectedMethods; + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + cacheSystemPrompt: true, + }) as Runner & RunnerProtectedMethods; const body = runner['toAnthropicRequestBody']( 'claude-3-5-haiku', @@ -1839,8 +1853,11 @@ describe('Runner request bodies and error branches', () => { it('should include optional config fields in streaming request body', () => { const mockClient = createMockAnthropicClient(); - const runner = new Runner('claude-3-5-haiku', mockClient, true) as Runner & - RunnerProtectedMethods; + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + cacheSystemPrompt: true, + }) as Runner & RunnerProtectedMethods; const body = runner['toAnthropicStreamingRequestBody']( 'claude-3-5-haiku', @@ -1889,8 +1906,11 @@ describe('Runner request bodies and error branches', () => { it('should throw descriptive errors for missing tool refs', () => { const mockClient = createMockAnthropicClient(); - const runner = new Runner('claude-3-5-haiku', mockClient, false) as Runner & - RunnerProtectedMethods; + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + cacheSystemPrompt: false, + }) as Runner & RunnerProtectedMethods; assert.throws( () => From 94771a5982719ea28d2ba19d8fb39d7bc3d07e9a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 12:07:46 +0000 Subject: [PATCH 28/51] docs(anthropic): document shared helper --- js/plugins/anthropic/src/runner/base.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index ee26ca6aae..fdd2305d78 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -112,6 +112,12 @@ export abstract class BaseRunner { ); } + /** + * Both the stable and beta Anthropic SDKs accept the same JSON shape for PDF + * document sources (either `type: 'base64'` with a base64 payload or `type: 'url'` + * with a public URL). Even though the return type references the stable SDK + * union, TypeScript’s structural typing lets the beta runner reuse this helper. + */ protected toPdfDocumentSource(media: Media): DocumentBlockParam['source'] { if (media.contentType !== 'application/pdf') { throw new Error( From 5f0d0c257fb52075742e400a7d819145b4f2600d Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 12:46:15 +0000 Subject: [PATCH 29/51] refactor(anthropic): handle stable server tool responses --- js/plugins/anthropic/src/runner/beta.ts | 52 ++++++++- js/plugins/anthropic/src/runner/stable.ts | 109 +++++++++++++----- .../anthropic/tests/beta_runner_test.ts | 4 +- .../anthropic/tests/stable_runner_test.ts | 2 +- 4 files changed, 131 insertions(+), 36 deletions(-) diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index d7793e200f..06a00ee3a0 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -47,6 +47,24 @@ import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { BaseRunner } from './base.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', + 'container_upload', +]); + type BetaToolUseLike = | BetaToolUseBlock | BetaServerToolUseBlock @@ -318,6 +336,15 @@ 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( + `Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.` + ); + } return this.fromBetaContentBlock(event.content_block); } if (event.type === 'content_block_delta') { @@ -336,8 +363,8 @@ export class BetaRunner extends BaseRunner { private fromBetaContentBlock(contentBlock: BetaContentBlock): Part { switch (contentBlock.type) { case 'tool_use': - case 'server_tool_use': case 'mcp_tool_use': + case 'server_tool_use': return { toolRequest: { ref: contentBlock.id, @@ -345,13 +372,34 @@ export class BetaRunner extends BaseRunner { input: contentBlock.input, }, }; + + case 'web_search_tool_result': + return { + text: `[Anthropic server tool result ${contentBlock.tool_use_id}] ${JSON.stringify(contentBlock.content)}`, + custom: { + anthropicServerToolResult: { + type: contentBlock.type, + toolUseId: contentBlock.tool_use_id, + content: contentBlock.content, + }, + }, + }; + case 'text': return { text: contentBlock.text }; + case 'thinking': return { text: contentBlock.thinking }; + case 'redacted_thinking': - return { text: contentBlock.data }; + return { custom: { redactedThinking: contentBlock.data } }; + default: { + if (BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(contentBlock.type)) { + throw new Error( + `Genkit Anthropic plugin does not yet support server-managed tool block '${contentBlock.type}'. Please retry against the stable API or wait for dedicated support.` + ); + } const unknownType = (contentBlock as { type: string }).type; console.warn( `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify( diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index ce63e0524e..3e9b3885e4 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -316,46 +316,89 @@ export class Runner extends BaseRunner { if (event.type === 'content_block_start') { const block = event.content_block; - if (block.type === 'text') { - return { text: block.text }; - } - - if (block.type === 'thinking') { - return { text: block.thinking }; + 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 { + text: `[Anthropic server tool result ${block.tool_use_id}] ${JSON.stringify(block.content)}`, + custom: { + anthropicServerToolResult: { + type: block.type, + toolUseId: block.tool_use_id, + content: block.content, + }, + }, + }; + + case 'text': + return { text: block.text }; + + case 'thinking': + return { text: block.thinking }; + + 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; + console.warn( + `Unexpected Anthropic content block type in stream: ${unknownType}. Returning undefined. Content block: ${JSON.stringify(block)}` + ); + return undefined; + } } + } - if (block.type === 'redacted_thinking') { - return { text: block.data }; - } + // Other event types (message_start, message_delta, etc.) - ignore + return undefined; + } - if (block.type === 'tool_use') { + protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { + switch (contentBlock.type) { + case 'server_tool_use': return { - toolRequest: { - ref: block.id, - name: block.name, - input: block.input, + text: `[Anthropic server tool ${contentBlock.name}] input: ${JSON.stringify(contentBlock.input)}`, + custom: { + anthropicServerToolUse: { + id: contentBlock.id, + name: contentBlock.name, + input: contentBlock.input, + }, }, }; - } - // server_tool_use, web_search_tool_result, or unknown types - treat as tool_use - if ('id' in block && 'name' in block) { + case 'web_search_tool_result': return { - toolRequest: { - ref: block.id, - name: block.name, - input: 'input' in block ? block.input : undefined, + text: `[Anthropic server tool result ${contentBlock.tool_use_id}] ${JSON.stringify(contentBlock.content)}`, + custom: { + anthropicServerToolResult: { + type: contentBlock.type, + toolUseId: contentBlock.tool_use_id, + content: contentBlock.content, + }, }, }; - } - } - - // Other event types (message_start, message_delta, etc.) - ignore - return undefined; - } - protected fromAnthropicContentBlock(contentBlock: ContentBlock): Part { - switch (contentBlock.type) { case 'tool_use': return { toolRequest: { @@ -364,15 +407,17 @@ export class Runner extends BaseRunner { input: contentBlock.input, }, }; + case 'text': return { text: contentBlock.text }; + case 'thinking': return { text: contentBlock.thinking }; + case 'redacted_thinking': - return { text: contentBlock.data }; + return { custom: { redactedThinking: contentBlock.data } }; + default: { - // Handle unexpected content block types - // Log warning for debugging, but return empty text to avoid breaking the flow const unknownType = (contentBlock as { type: string }).type; console.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 46b8515350..923e2aa301 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -430,7 +430,9 @@ describe('BetaRunner', () => { type: 'redacted_thinking', data: '[redacted]', }); - assert.deepStrictEqual(redactedPart, { text: '[redacted]' }); + assert.deepStrictEqual(redactedPart, { + custom: { redactedThinking: '[redacted]' }, + }); const toolPart = (runner as any).fromBetaContentBlock({ type: 'tool_use', diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index d568db6d5c..ec12fc79b7 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -694,7 +694,7 @@ describe('fromAnthropicContentBlockChunk', () => { data: 'encrypted-data', }, }, - expectedOutput: { text: 'encrypted-data' }, + expectedOutput: { custom: { redactedThinking: 'encrypted-data' } }, }, { should: 'should return text delta part from content_block_delta event', From e2840a54be5def8e92cc97c52c557b4686580b2a Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 13:01:30 +0000 Subject: [PATCH 30/51] refactor(anthropic): add some code comments and extract web search mapper --- js/plugins/anthropic/src/runner/base.ts | 25 +++++++++++++++++++ js/plugins/anthropic/src/runner/beta.ts | 15 ++++-------- js/plugins/anthropic/src/runner/stable.ts | 30 ++++++++--------------- js/plugins/anthropic/src/runner/types.ts | 23 ++++++++++++++++- 4 files changed, 62 insertions(+), 31 deletions(-) diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index fdd2305d78..3dbce27853 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -50,6 +50,13 @@ import { RunnerTypes, } from './types.js'; +/** + * Shared runner logic for Anthropic SDK integrations. + * + * Concrete subclasses pass in their SDK-specific type bundle via `RunnerTypes`, + * letting this base class handle message/tool translation once for both the + * stable and beta APIs that share the same conceptual surface. + */ export abstract class BaseRunner { protected name: string; protected client: Anthropic; @@ -232,6 +239,24 @@ export abstract class BaseRunner { }; } + 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 06a00ee3a0..dad4492cc4 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -374,16 +374,11 @@ export class BetaRunner extends BaseRunner { }; case 'web_search_tool_result': - return { - text: `[Anthropic server tool result ${contentBlock.tool_use_id}] ${JSON.stringify(contentBlock.content)}`, - custom: { - anthropicServerToolResult: { - type: contentBlock.type, - toolUseId: contentBlock.tool_use_id, - content: contentBlock.content, - }, - }, - }; + return this.toWebSearchToolResultPart({ + type: contentBlock.type, + toolUseId: contentBlock.tool_use_id, + content: contentBlock.content, + }); case 'text': return { text: contentBlock.text }; diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 3e9b3885e4..8535a161df 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -330,16 +330,11 @@ export class Runner extends BaseRunner { }; case 'web_search_tool_result': - return { - text: `[Anthropic server tool result ${block.tool_use_id}] ${JSON.stringify(block.content)}`, - custom: { - anthropicServerToolResult: { - type: block.type, - toolUseId: block.tool_use_id, - content: block.content, - }, - }, - }; + return this.toWebSearchToolResultPart({ + type: block.type, + toolUseId: block.tool_use_id, + content: block.content, + }); case 'text': return { text: block.text }; @@ -388,16 +383,11 @@ export class Runner extends BaseRunner { }; case 'web_search_tool_result': - return { - text: `[Anthropic server tool result ${contentBlock.tool_use_id}] ${JSON.stringify(contentBlock.content)}`, - custom: { - anthropicServerToolResult: { - type: contentBlock.type, - toolUseId: contentBlock.tool_use_id, - content: contentBlock.content, - }, - }, - }; + return this.toWebSearchToolResultPart({ + type: contentBlock.type, + toolUseId: contentBlock.tool_use_id, + content: contentBlock.content, + }); case 'tool_use': return { diff --git a/js/plugins/anthropic/src/runner/types.ts b/js/plugins/anthropic/src/runner/types.ts index 952018423b..f13c86266a 100644 --- a/js/plugins/anthropic/src/runner/types.ts +++ b/js/plugins/anthropic/src/runner/types.ts @@ -16,7 +16,14 @@ */ /** - * Type constraint for runner type parameters. + * Type contract that each Anthropic runner passes into the generic `BaseRunner`. + * + * The concrete runners (stable vs. beta SDKs) bind these slots to their SDK’s + * concrete interfaces so the shared logic in `BaseRunner` can stay strongly typed + * without knowing which SDK variant it is talking to. + * + * Properties are `unknown` by default, so every subclass must plug in the + * correct Anthropic types to keep the generic plumbing sound. */ type RunnerTypes = { Message: unknown; @@ -31,16 +38,30 @@ type RunnerTypes = { }; type RunnerMessage = ApiTypes['Message']; + +/** Streaming handle that yields Anthropic events and exposes the final message. */ type RunnerStream = ApiTypes['Stream']; + +/** Discrete event emitted by the Anthropic stream (delta, block start, etc.). */ type RunnerStreamEvent = ApiTypes['StreamEvent']; + +/** Non-streaming request payload shape for create-message calls. */ type RunnerRequestBody = ApiTypes['RequestBody']; type RunnerStreamingRequestBody = ApiTypes['StreamingRequestBody']; + +/** Tool definition compatible with the target Anthropic SDK. */ type RunnerTool = ApiTypes['Tool']; + +/** Anthropic message param shape used when sending history to the API. */ type RunnerMessageParam = ApiTypes['MessageParam']; + +/** Content block that the runner sends to Anthropic for a single part. */ type RunnerContentBlockParam = ApiTypes['ContentBlockParam']; + +/** Tool response block that Anthropic expects when returning tool output. */ type RunnerToolResponseContent = ApiTypes['ToolResponseContent']; From 4086727202bef6b040b0f76d8310c94fea42a529 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 13:34:22 +0000 Subject: [PATCH 31/51] feat(anthropic): handle remote image URLs --- js/plugins/anthropic/src/runner/base.ts | 58 ++++++++++++------- js/plugins/anthropic/src/runner/beta.ts | 17 ++++-- js/plugins/anthropic/src/runner/stable.ts | 17 ++++-- .../anthropic/tests/stable_runner_test.ts | 52 ++++++++++++----- 4 files changed, 102 insertions(+), 42 deletions(-) diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index 3dbce27853..e7996ef4cd 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -157,29 +157,47 @@ export abstract class BaseRunner { }; } - protected toImageSource(media: Media): { - data: string; - mediaType: MediaType; - } { - const extracted = this.extractDataFromBase64Url(media.url); - const { data, contentType } = extracted ?? {}; - if (!data || !contentType) { - throw new Error( - `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( - media - )}.` - ); - } - const resolvedMediaType = media.contentType ?? contentType; - if (!resolvedMediaType) { - throw new Error('Media type is required but was not provided'); + /** + * Normalizes Genkit `Media` into either a base64 payload or a remote URL + * accepted by the Anthropic SDK. Anthropic supports both `data:` URLs (which + * we forward as base64) and remote `https` URLs without additional handling. + */ + protected toImageSource( + media: Media + ): + | { kind: 'base64'; data: string; mediaType: MediaType } + | { kind: 'url'; url: string } { + if (this.isDataUrl(media.url)) { + const extracted = this.extractDataFromBase64Url(media.url); + const { data, contentType } = extracted ?? {}; + if (!data || !contentType) { + throw new Error( + `Invalid genkit part media provided to toAnthropicMessageContent: ${JSON.stringify( + media + )}.` + ); + } + const resolvedMediaType = media.contentType ?? contentType; + if (!resolvedMediaType) { + throw new Error('Media type is required but was not provided'); + } + if (!this.isMediaType(resolvedMediaType)) { + throw new Error(`Unsupported media type: ${resolvedMediaType}`); + } + return { + kind: 'base64', + data, + mediaType: resolvedMediaType, + }; } - if (!this.isMediaType(resolvedMediaType)) { - throw new Error(`Unsupported media type: ${resolvedMediaType}`); + + if (!media.url) { + throw new Error('Media url is required but was not provided'); } + return { - data, - mediaType: resolvedMediaType, + kind: 'url', + url: media.url, }; } diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index dad4492cc4..23e7ed0492 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -122,13 +122,22 @@ export class BetaRunner extends BaseRunner { }; } - const { data, mediaType } = this.toImageSource(part.media); + const source = this.toImageSource(part.media); + if (source.kind === 'base64') { + return { + type: 'image', + source: { + type: 'base64', + data: source.data, + media_type: source.mediaType, + }, + }; + } return { type: 'image', source: { - type: 'base64', - data, - media_type: mediaType, + type: 'url', + url: source.url, }, }; } diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 8535a161df..6dd5a4af95 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -87,13 +87,22 @@ export class Runner extends BaseRunner { }; } - const { data, mediaType } = this.toImageSource(part.media); + const source = this.toImageSource(part.media); + if (source.kind === 'base64') { + return { + type: 'image', + source: { + type: 'base64', + data: source.data, + media_type: source.mediaType, + }, + }; + } return { type: 'image', source: { - type: 'base64', - data, - media_type: mediaType, + type: 'url', + url: source.url, }, }; } diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index ec12fc79b7..14585eddf7 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -159,7 +159,7 @@ describe('toAnthropicMessageContent', () => { url: '', }, }), - /Invalid genkit part media provided to toAnthropicMessageContent: {"url":""}/ + /Media url is required but was not provided/ ); }); @@ -170,19 +170,20 @@ describe('toAnthropicMessageContent', () => { ); }); - it('should throw if media with file URL cannot be processed as image', () => { - // File URLs without contentType cannot be processed (they're not data URLs) - // This will fall through to image handling which requires data URLs - assert.throws( - () => - testRunner.toAnthropicMessageContent({ - media: { - url: 'https://example.com/document.pdf', - // contentType missing - won't be recognized as PDF - }, - }), - /Invalid genkit part media provided to toAnthropicMessageContent/ - ); + it('should treat remote URLs without explicit content type as image URLs', () => { + const result = testRunner.toAnthropicMessageContent({ + media: { + url: 'https://example.com/image.png', + }, + }); + + assert.deepStrictEqual(result, { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/image.png', + }, + }); }); it('should handle PDF with base64 data URL correctly', () => { @@ -1791,9 +1792,32 @@ describe('BaseRunner helper utilities', () => { contentType: 'image/png', }); + assert.strictEqual(source.kind, 'base64'); + if (source.kind !== 'base64') { + throw new Error('Expected base64 image source'); + } assert.strictEqual(source.mediaType, 'image/png'); assert.strictEqual(source.data, 'AAA'); }); + + it('should pass through remote image URLs', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); + + const source = runner['toImageSource']({ + url: 'https://example.com/image.png', + contentType: 'image/png', + }); + + assert.strictEqual(source.kind, 'url'); + if (source.kind !== 'url') { + throw new Error('Expected url image source'); + } + assert.strictEqual(source.url, 'https://example.com/image.png'); + }); }); describe('Runner request bodies and error branches', () => { From e7d3731ddf078fcd7f46d2bb7ec002eb7e14d6f5 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 14:00:28 +0000 Subject: [PATCH 32/51] fix(anthropic): some system prompt and image handling issues --- js/plugins/anthropic/src/runner/base.ts | 31 ++- js/plugins/anthropic/src/runner/beta.ts | 53 ++-- .../anthropic/tests/beta_runner_test.ts | 233 ++++++++++++++++-- .../anthropic/tests/stable_runner_test.ts | 129 ++++++++++ 4 files changed, 399 insertions(+), 47 deletions(-) diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index e7996ef4cd..c196d78979 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -292,12 +292,33 @@ export abstract class BaseRunner { system?: string; messages: RunnerMessageParam[]; } { - const system = - messages[0]?.role === 'system' - ? messages[0].content?.[0]?.text - : undefined; + let system: string | undefined; + + if (messages[0]?.role === 'system') { + const systemMessage = messages[0]; + const textParts: string[] = []; + + for (const part of systemMessage.content ?? []) { + if (part.text) { + textParts.push(part.text); + } else if (part.media || part.toolRequest || part.toolResponse) { + throw new Error( + 'System messages can only contain text content. Media, tool requests, and tool responses are not supported in system messages.' + ); + } + } + + // Concatenate multiple text parts into a single string. + // Note: The Anthropic SDK supports system as string | Array, + // so we could alternatively preserve the multi-part structure as: + // system = textParts.map(text => ({ type: 'text', text })) + // However, concatenation is simpler and maintains semantic equivalence while + // keeping the cache control logic straightforward in the concrete runners. + system = textParts.length > 0 ? textParts.join('\n\n') : undefined; + } - const messagesToIterate = system ? messages.slice(1) : messages; + const messagesToIterate = + system !== undefined ? messages.slice(1) : messages; const anthropicMsgs: RunnerMessageParam[] = []; for (const message of messagesToIterate) { diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 23e7ed0492..8266a60395 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -19,7 +19,6 @@ import { BetaMessageStream } from '@anthropic-ai/sdk/lib/BetaMessageStream.js'; import type { BetaContentBlock, BetaImageBlockParam, - BetaMCPToolUseBlock, BetaMessage, MessageCreateParams as BetaMessageCreateParams, MessageCreateParamsNonStreaming as BetaMessageCreateParamsNonStreaming, @@ -27,12 +26,10 @@ import type { BetaMessageParam, BetaRawMessageStreamEvent, BetaRequestDocumentBlock, - BetaServerToolUseBlock, BetaStopReason, BetaTextBlockParam, BetaTool, BetaToolResultBlockParam, - BetaToolUseBlock, BetaToolUseBlockParam, } from '@anthropic-ai/sdk/resources/beta/messages'; @@ -62,13 +59,12 @@ const BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES = new Set([ 'bash_code_execution_tool_result', 'text_editor_code_execution_tool_result', 'mcp_tool_result', + 'mcp_tool_use', 'container_upload', ]); -type BetaToolUseLike = - | BetaToolUseBlock - | BetaServerToolUseBlock - | BetaMCPToolUseBlock; +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.`; type BetaRunnerTypes = { Message: BetaMessage; @@ -350,9 +346,7 @@ export class BetaRunner extends BaseRunner { blockType && BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(blockType) ) { - throw new Error( - `Anthropic beta runner does not yet support server-managed tool block '${blockType}'. Please retry against the stable API or wait for dedicated support.` - ); + throw new Error(unsupportedServerToolError(blockType)); } return this.fromBetaContentBlock(event.content_block); } @@ -371,16 +365,36 @@ export class BetaRunner extends BaseRunner { private fromBetaContentBlock(contentBlock: BetaContentBlock): Part { switch (contentBlock.type) { - case 'tool_use': - case 'mcp_tool_use': - case 'server_tool_use': + case 'tool_use': { return { toolRequest: { ref: contentBlock.id, - name: this.betaToolName(contentBlock), + 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({ @@ -400,9 +414,7 @@ export class BetaRunner extends BaseRunner { default: { if (BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES.has(contentBlock.type)) { - throw new Error( - `Genkit Anthropic plugin does not yet support server-managed tool block '${contentBlock.type}'. Please retry against the stable API or wait for dedicated support.` - ); + throw new Error(unsupportedServerToolError(contentBlock.type)); } const unknownType = (contentBlock as { type: string }).type; console.warn( @@ -415,13 +427,6 @@ export class BetaRunner extends BaseRunner { } } - private betaToolName(block: BetaToolUseLike): string { - if ('server_name' in block && block.server_name) { - return `${block.server_name}/${block.name}`; - } - return block.name ?? 'unknown_tool'; - } - private fromBetaStopReason( reason: BetaStopReason | null ): ModelResponseData['finishReason'] { diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 923e2aa301..273af968ef 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -215,10 +215,13 @@ describe('BetaRunner', () => { } as any; const toolPart = exposed.toGenkitPart(serverToolEvent); assert.deepStrictEqual(toolPart, { - toolRequest: { - ref: 'toolu_test', - name: 'srv/myTool', - input: { foo: 'bar' }, + text: '[Anthropic server tool srv/myTool] input: {"foo":"bar"}', + custom: { + anthropicServerToolUse: { + id: 'toolu_test', + name: 'srv/myTool', + input: { foo: 'bar' }, + }, }, }); @@ -233,6 +236,29 @@ describe('BetaRunner', () => { assert.strictEqual(ignored, undefined); }); + it('should throw on unsupported mcp tool stream events', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, + }); + + const exposed = runner as any; + assert.throws( + () => + exposed.toGenkitPart({ + type: 'content_block_start', + index: 0, + content_block: { + type: 'mcp_tool_use', + id: 'toolu_unsupported', + input: {}, + }, + }), + /server-managed tool block 'mcp_tool_use'/ + ); + }); + it('should map beta stop reasons correctly', () => { const mockClient = createMockAnthropicClient(); const runner = new BetaRunner({ @@ -390,27 +416,180 @@ describe('BetaRunner', () => { assert.ok(Array.isArray(streamingBody.system)); }); - it('should fall back to unknown tool name when metadata missing', () => { + it('should concatenate multiple text parts in system message', () => { const mockClient = createMockAnthropicClient(); const runner = new BetaRunner({ - name: 'claude-test', + name: 'claude-3-5-haiku', client: mockClient as Anthropic, - }); - const exposed = runner as any; + }) as any; - const part = exposed.fromBetaContentBlock({ - type: 'mcp_tool_use', - id: 'toolu_unknown', - input: {}, - }); + const request = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { text: 'Always be concise.' }, + { text: 'Use proper grammar.' }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + } satisfies any; - assert.deepStrictEqual(part, { - toolRequest: { - ref: 'toolu_unknown', - name: 'unknown_tool', - input: {}, + const body = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false + ); + + assert.strictEqual( + body.system, + 'You are a helpful assistant.\n\nAlways be concise.\n\nUse proper grammar.' + ); + }); + + it('should concatenate multiple text parts in system message with caching', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { text: 'Always be concise.' }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + } satisfies any; + + const body = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + true + ); + + assert.ok(Array.isArray(body.system)); + assert.deepStrictEqual(body.system, [ + { + type: 'text', + text: 'You are a helpful assistant.\n\nAlways be concise.', + cache_control: { type: 'ephemeral' }, }, + ]); + }); + + it('should throw error if system message contains media', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { + media: { + url: 'data:image/png;base64,iVBORw0KGgoAAAANS', + contentType: 'image/png', + }, + }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + } satisfies any; + + assert.throws( + () => runner.toAnthropicRequestBody('claude-3-5-haiku', request, false), + /System messages can only contain text content/ + ); + }); + + it('should throw error if system message contains tool requests', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { toolRequest: { name: 'getTool', input: {}, ref: '123' } }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + } satisfies any; + + assert.throws( + () => runner.toAnthropicRequestBody('claude-3-5-haiku', request, false), + /System messages can only contain text content/ + ); + }); + + it('should throw error if system message contains tool responses', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-3-5-haiku', + client: mockClient as Anthropic, + }) as any; + + const request = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { toolResponse: { name: 'getTool', output: {}, ref: '123' } }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + } satisfies any; + + assert.throws( + () => runner.toAnthropicRequestBody('claude-3-5-haiku', request, false), + /System messages can only contain text content/ + ); + }); + + it('should throw for unsupported mcp tool use blocks', () => { + const mockClient = createMockAnthropicClient(); + const runner = new BetaRunner({ + name: 'claude-test', + client: mockClient as Anthropic, }); + const exposed = runner as any; + + assert.throws( + () => + exposed.fromBetaContentBlock({ + type: 'mcp_tool_use', + id: 'toolu_unknown', + input: {}, + }), + /server-managed tool block 'mcp_tool_use'/ + ); }); it('should convert additional beta content block types', () => { @@ -448,6 +627,24 @@ describe('BetaRunner', () => { }, }); + const serverToolPart = (runner as any).fromBetaContentBlock({ + type: 'server_tool_use', + id: 'srv_tool_1', + name: 'serverTool', + input: { arg: 'value' }, + server_name: 'srv', + }); + assert.deepStrictEqual(serverToolPart, { + text: '[Anthropic server tool srv/serverTool] input: {"arg":"value"}', + custom: { + anthropicServerToolUse: { + id: 'srv_tool_1', + name: 'srv/serverTool', + input: { arg: 'value' }, + }, + }, + }); + const warnMock = mock.method(console, 'warn', () => {}); const fallbackPart = (runner as any).fromBetaContentBlock({ type: 'mystery', diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 14585eddf7..a81dbea52d 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -1073,6 +1073,135 @@ describe('toAnthropicRequestBody', () => { 'You are a helpful assistant' ); }); + + it('should concatenate multiple text parts in system message', () => { + const request: GenerateRequest = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { text: 'Always be concise.' }, + { text: 'Use proper grammar.' }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + }; + + const output = testRunner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + false + ); + + assert.strictEqual( + output.system, + 'You are a helpful assistant.\n\nAlways be concise.\n\nUse proper grammar.' + ); + }); + + it('should concatenate multiple text parts in system message with caching', () => { + const request: GenerateRequest = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { text: 'Always be concise.' }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + }; + + const output = testRunner.toAnthropicRequestBody( + 'claude-3-5-haiku', + request, + true + ); + + assert.deepStrictEqual(output.system, [ + { + type: 'text', + text: 'You are a helpful assistant.\n\nAlways be concise.', + cache_control: { type: 'ephemeral' }, + }, + ]); + }); + + it('should throw error if system message contains media', () => { + const request: GenerateRequest = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { + media: { + url: 'data:image/png;base64,iVBORw0KGgoAAAANS', + contentType: 'image/png', + }, + }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + }; + + assert.throws( + () => + testRunner.toAnthropicRequestBody('claude-3-5-haiku', request, false), + /System messages can only contain text content/ + ); + }); + + it('should throw error if system message contains tool requests', () => { + const request: GenerateRequest = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { toolRequest: { name: 'getTool', input: {}, ref: '123' } }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + }; + + assert.throws( + () => + testRunner.toAnthropicRequestBody('claude-3-5-haiku', request, false), + /System messages can only contain text content/ + ); + }); + + it('should throw error if system message contains tool responses', () => { + const request: GenerateRequest = { + messages: [ + { + role: 'system', + content: [ + { text: 'You are a helpful assistant.' }, + { toolResponse: { name: 'getTool', output: {}, ref: '123' } }, + ], + }, + { role: 'user', content: [{ text: 'Hi' }] }, + ], + output: { format: 'text' }, + }; + + assert.throws( + () => + testRunner.toAnthropicRequestBody('claude-3-5-haiku', request, false), + /System messages can only contain text content/ + ); + }); }); describe('toAnthropicStreamingRequestBody', () => { From aaf93148bdf5c535b9ad56170cc25c5be21d6ab3 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 14:18:43 +0000 Subject: [PATCH 33/51] docs(anthropic): have runner interfaces extend the base interface --- js/plugins/anthropic/package.json | 7 +- js/plugins/anthropic/src/index.ts | 118 ++++-- js/plugins/anthropic/src/models.ts | 54 +-- js/plugins/anthropic/src/runner/base.ts | 1 - js/plugins/anthropic/src/runner/beta.ts | 6 +- js/plugins/anthropic/src/runner/index.ts | 1 - js/plugins/anthropic/src/runner/stable.ts | 7 +- js/plugins/anthropic/src/runner/types.ts | 1 - js/plugins/anthropic/tests/execution_test.ts | 358 +++++++++++++++++ js/plugins/anthropic/tests/index_test.ts | 86 ++++- js/plugins/anthropic/tests/streaming_test.ts | 384 +++++++++++++++++++ js/pnpm-lock.yaml | 51 +++ js/testapps/anthropic-basic/package.json | 10 +- 13 files changed, 1012 insertions(+), 72 deletions(-) create mode 100644 js/plugins/anthropic/tests/execution_test.ts create mode 100644 js/plugins/anthropic/tests/streaming_test.ts diff --git a/js/plugins/anthropic/package.json b/js/plugins/anthropic/package.json index fba53dafea..e3c3e5f036 100644 --- a/js/plugins/anthropic/package.json +++ b/js/plugins/anthropic/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@types/node": "^20.11.16", + "check-node-version": "^4.2.1", "genkit": "workspace:*", "npm-run-all": "^4.1.5", "rimraf": "^6.0.1", @@ -62,8 +63,8 @@ "build:clean": "rimraf ./lib", "build": "npm-run-all build:clean check compile", "build:watch": "tsup-node --watch", - "test": "tsx --test --experimental-test-module-mocks tests/*_test.ts", - "test:file": "tsx --test --experimental-test-module-mocks", - "test:coverage": "tsx --test --experimental-test-module-mocks --experimental-test-coverage --test-coverage-include='src/**/*.ts' ./tests/**/*_test.ts" + "test": "tsx --test tests/*_test.ts", + "test:file": "tsx --test", + "test:coverage": "check-node-version --node '>=22' && tsx --test --experimental-test-coverage --test-coverage-include='src/**/*.ts' ./tests/**/*_test.ts" } } diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index dba400ab9a..c1450059a4 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -29,52 +29,124 @@ import { AnthropicConfigSchemaType, ClaudeConfig, ClaudeModelName, + GENERIC_CLAUDE_MODEL_INFO, KNOWN_CLAUDE_MODELS, KnownClaudeModels, claude35Haiku, claude3Haiku, - claude41Opus, - claude45Haiku, - claude45Sonnet, - claude4Opus, - claude4Sonnet, + claudeHaiku45, claudeModel, claudeModelReference, + claudeOpus4, + claudeOpus41, + claudeSonnet4, + claudeSonnet45, } from './models.js'; -import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; +import { + AnthropicConfigSchema, + InternalPluginOptions, + PluginOptions, + __testClient, +} from './types.js'; export { claude35Haiku, claude3Haiku, - claude41Opus, - claude45Haiku, - claude45Sonnet, - claude4Opus, - claude4Sonnet, + claudeHaiku45, + claudeOpus4, + claudeOpus41, + claudeSonnet4, + claudeSonnet45, }; +function normalizeModelId(modelId: string): string { + return modelId.replace(/-(?:\d{8}|latest)$/i, ''); +} + +type ModelMetadataParams = Parameters[0]; + async function list(client: Anthropic): Promise { const clientModels = (await client.models.list()).data; - const result: ActionMetadata[] = []; + const metadataByName = new Map(); + const orderedNames: string[] = []; for (const modelInfo of clientModels) { - // Remove the date suffix from the model id - const normalizedId = modelInfo.id.replace(/-\d{8}$/, ''); - // Get the model reference from the supported models + const modelId = modelInfo.id; + if (!modelId) { + continue; + } + + const normalizedId = normalizeModelId(modelId); const ref = KNOWN_CLAUDE_MODELS[normalizedId]; - // Add the model action metadata if the model is supported + if (ref) { - result.push( - modelActionMetadata({ - name: ref.name, - info: ref.info, - configSchema: ref.configSchema, - }) + const existing = metadataByName.get(ref.name); + const baseInfo = existing?.info ?? ref.info; + const mergedVersions = new Set( + baseInfo?.versions ?? ref.info?.versions ?? [] ); + mergedVersions.add(modelId); + + const info = { + ...baseInfo, + versions: Array.from(mergedVersions), + }; + + metadataByName.set(ref.name, { + name: ref.name, + info, + configSchema: ref.configSchema, + }); + + if (!existing) { + orderedNames.push(ref.name); + } + continue; } + + const fallbackName = `anthropic/${modelId}`; + const existingFallback = metadataByName.get(fallbackName); + const fallbackLabel = + modelInfo.display_name ?? + (normalizedId !== modelId + ? `Anthropic - ${normalizedId}` + : `Anthropic - ${modelId}`); + + if (existingFallback) { + const info = { + ...existingFallback.info, + versions: existingFallback.info?.versions + ? Array.from( + new Set([...(existingFallback.info.versions ?? []), modelId]) + ) + : [modelId], + }; + metadataByName.set(fallbackName, { + ...existingFallback, + info, + }); + continue; + } + + metadataByName.set(fallbackName, { + name: fallbackName, + info: { + ...GENERIC_CLAUDE_MODEL_INFO, + label: fallbackLabel, + versions: modelId ? [modelId] : [...GENERIC_CLAUDE_MODEL_INFO.versions], + supports: { + ...GENERIC_CLAUDE_MODEL_INFO.supports, + output: [...GENERIC_CLAUDE_MODEL_INFO.supports.output], + }, + }, + configSchema: AnthropicConfigSchema, + }); + orderedNames.push(fallbackName); } - return result; + return orderedNames.map((name) => + modelActionMetadata(metadataByName.get(name)!) + ); } /** diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 38491de089..5f329e8dfd 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -35,12 +35,12 @@ import { type ClaudeRunnerParams, } from './types.js'; -export const claude4Sonnet = modelRef({ - name: 'claude-4-sonnet', +export const claudeSonnet4 = modelRef({ + name: 'claude-sonnet-4', namespace: 'anthropic', info: { versions: ['claude-sonnet-4-20250514'], - label: 'Anthropic - Claude 4 Sonnet', + label: 'Anthropic - Claude Sonnet 4', supports: { multiturn: true, tools: true, @@ -71,12 +71,12 @@ export const claude3Haiku = modelRef({ version: 'claude-3-haiku-20240307', }); -export const claude4Opus = modelRef({ - name: 'claude-4-opus', +export const claudeOpus4 = modelRef({ + name: 'claude-opus-4', namespace: 'anthropic', info: { versions: ['claude-opus-4-20250514'], - label: 'Anthropic - Claude 4 Opus', + label: 'Anthropic - Claude Opus 4', supports: { multiturn: true, tools: true, @@ -107,12 +107,12 @@ export const claude35Haiku = modelRef({ version: 'claude-3-5-haiku-latest', }); -export const claude45Sonnet = modelRef({ - name: 'claude-4-5-sonnet', +export const claudeSonnet45 = modelRef({ + name: 'claude-sonnet-4.5', namespace: 'anthropic', info: { - versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5-latest'], - label: 'Anthropic - Claude 4.5 Sonnet', + versions: ['claude-sonnet-4.5-20250929', 'claude-sonnet-4.5-latest'], + label: 'Anthropic - Claude Sonnet 4.5', supports: { multiturn: true, tools: true, @@ -122,15 +122,15 @@ export const claude45Sonnet = modelRef({ }, }, configSchema: AnthropicConfigSchema, - version: 'claude-sonnet-4-5-latest', + version: 'claude-sonnet-4.5-latest', }); -export const claude45Haiku = modelRef({ - name: 'claude-4-5-haiku', +export const claudeHaiku45 = modelRef({ + name: 'claude-haiku-4.5', namespace: 'anthropic', info: { - versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5-latest'], - label: 'Anthropic - Claude 4.5 Haiku', + versions: ['claude-haiku-4.5-20251001', 'claude-haiku-4.5-latest'], + label: 'Anthropic - Claude Haiku 4.5', supports: { multiturn: true, tools: true, @@ -140,15 +140,15 @@ export const claude45Haiku = modelRef({ }, }, configSchema: AnthropicConfigSchema, - version: 'claude-haiku-4-5-latest', + version: 'claude-haiku-4.5-latest', }); -export const claude41Opus = modelRef({ - name: 'claude-4-1-opus', +export const claudeOpus41 = modelRef({ + name: 'claude-opus-4.1', namespace: 'anthropic', info: { - versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1-latest'], - label: 'Anthropic - Claude 4.1 Opus', + versions: ['claude-opus-4.1-20250805', 'claude-opus-4.1-latest'], + label: 'Anthropic - Claude Opus 4.1', supports: { multiturn: true, tools: true, @@ -158,7 +158,7 @@ export const claude41Opus = modelRef({ }, }, configSchema: AnthropicConfigSchema, - version: 'claude-opus-4-1-latest', + version: 'claude-opus-4.1-latest', }); export const KNOWN_CLAUDE_MODELS: Record< @@ -167,18 +167,18 @@ export const KNOWN_CLAUDE_MODELS: Record< > = { 'claude-3-haiku': claude3Haiku, 'claude-3-5-haiku': claude35Haiku, - 'claude-4-sonnet': claude4Sonnet, - 'claude-4-opus': claude4Opus, - 'claude-4-5-sonnet': claude45Sonnet, - 'claude-4-5-haiku': claude45Haiku, - 'claude-4-1-opus': claude41Opus, + 'claude-sonnet-4': claudeSonnet4, + 'claude-opus-4': claudeOpus4, + 'claude-sonnet-4.5': claudeSonnet45, + 'claude-haiku-4.5': claudeHaiku45, + 'claude-opus-4.1': claudeOpus41, }; /** * Generic Claude model info for unknown/unsupported models. * Used when a model name is not in KNOWN_CLAUDE_MODELS. */ -const GENERIC_CLAUDE_MODEL_INFO = { +export const GENERIC_CLAUDE_MODEL_INFO = { versions: [], label: 'Anthropic - Claude', supports: { diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index c196d78979..b1d1ffd992 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -1,5 +1,4 @@ /** - * Original work Copyright 2024 Bloom Labs Inc * Modifications Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 8266a60395..1e67eb3ada 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -1,5 +1,4 @@ /** - * Original work Copyright 2024 Bloom Labs Inc * Modifications Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -43,6 +42,7 @@ import type { import { KNOWN_CLAUDE_MODELS } from '../models.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 @@ -66,7 +66,7 @@ const BETA_UNSUPPORTED_SERVER_TOOL_BLOCK_TYPES = new Set([ 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.`; -type BetaRunnerTypes = { +interface BetaRunnerTypes extends RunnerTypes { Message: BetaMessage; Stream: BetaMessageStream; StreamEvent: BetaRawMessageStreamEvent; @@ -81,7 +81,7 @@ type BetaRunnerTypes = { | BetaRequestDocumentBlock | BetaToolUseBlockParam | BetaToolResultBlockParam; -}; +} /** * Runner for the Anthropic Beta API. diff --git a/js/plugins/anthropic/src/runner/index.ts b/js/plugins/anthropic/src/runner/index.ts index 9be1981b3c..e6b6093fcb 100644 --- a/js/plugins/anthropic/src/runner/index.ts +++ b/js/plugins/anthropic/src/runner/index.ts @@ -1,5 +1,4 @@ /** - * Original work Copyright 2024 Bloom Labs Inc * Modifications Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 6dd5a4af95..29cf9836f7 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -1,5 +1,4 @@ /** - * Original work Copyright 2024 Bloom Labs Inc * Modifications Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -40,8 +39,8 @@ import type { import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { BaseRunner } from './base.js'; - -type RunnerTypes = { +import { RunnerTypes as BaseRunnerTypes } from './types.js'; +interface RunnerTypes extends BaseRunnerTypes { Message: Message; Stream: MessageStream; StreamEvent: MessageStreamEvent; @@ -56,7 +55,7 @@ type RunnerTypes = { | DocumentBlockParam | ToolUseBlockParam | ToolResultBlockParam; -}; +} export class Runner extends BaseRunner { constructor(params: ClaudeRunnerParams) { diff --git a/js/plugins/anthropic/src/runner/types.ts b/js/plugins/anthropic/src/runner/types.ts index f13c86266a..c39fa75f71 100644 --- a/js/plugins/anthropic/src/runner/types.ts +++ b/js/plugins/anthropic/src/runner/types.ts @@ -1,5 +1,4 @@ /** - * Original work Copyright 2024 Bloom Labs Inc * Modifications Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/js/plugins/anthropic/tests/execution_test.ts b/js/plugins/anthropic/tests/execution_test.ts new file mode 100644 index 0000000000..069d2d2dcd --- /dev/null +++ b/js/plugins/anthropic/tests/execution_test.ts @@ -0,0 +1,358 @@ +/** + * 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 type { GenerateRequest, ModelAction } from '@genkit-ai/ai/model'; +import * as assert from 'assert'; +import { describe, mock, test } from 'node:test'; +import { anthropic } from '../src/index.js'; +import { __testClient } from '../src/types.js'; +import { + createMockAnthropicClient, + createMockAnthropicMessage, +} from './mocks/anthropic-client.js'; + +describe('Model Execution Integration Tests', () => { + test('should resolve and execute a model via plugin', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Hello from Claude!', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + // Resolve the model action via plugin + const modelAction = plugin.resolve('model', 'claude-3-5-haiku-20241022'); + assert.ok(modelAction, 'Model should be resolved'); + assert.strictEqual( + (modelAction as ModelAction).__action.name, + 'anthropic/claude-3-5-haiku-20241022' + ); + + // Execute the model + const request: GenerateRequest = { + messages: [ + { + role: 'user', + content: [{ text: 'Hi there!' }], + }, + ], + }; + + const response = await (modelAction as ModelAction)(request, { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + }); + + assert.ok(response, 'Response should be returned'); + assert.ok(response.candidates, 'Response should have candidates'); + assert.strictEqual(response.candidates.length, 1); + assert.strictEqual(response.candidates[0].message.role, 'model'); + assert.strictEqual(response.candidates[0].message.content.length, 1); + assert.strictEqual( + response.candidates[0].message.content[0].text, + 'Hello from Claude!' + ); + + // Verify API was called + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + }); + + test('should handle multi-turn conversations', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'The capital of France is Paris.', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const request: GenerateRequest = { + messages: [ + { + role: 'user', + content: [{ text: 'What is your name?' }], + }, + { + role: 'model', + content: [{ text: 'I am Claude, an AI assistant.' }], + }, + { + role: 'user', + content: [{ text: 'What is the capital of France?' }], + }, + ], + }; + + const response = await modelAction(request, { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + }); + + assert.ok(response, 'Response should be returned'); + assert.strictEqual( + response.candidates[0].message.content[0].text, + 'The capital of France is Paris.' + ); + + // Verify API was called with multi-turn conversation + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + const apiRequest = createStub.mock.calls[0].arguments[0]; + assert.strictEqual(apiRequest.messages.length, 3); + }); + + test('should handle system messages', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Arr matey!', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const request: GenerateRequest = { + messages: [ + { + role: 'system', + content: [{ text: 'You are a pirate. Respond like a pirate.' }], + }, + { + role: 'user', + content: [{ text: 'Hello!' }], + }, + ], + }; + + const response = await modelAction(request, { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + }); + + assert.ok(response, 'Response should be returned'); + + // Verify system message was passed to API + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + const apiRequest = createStub.mock.calls[0].arguments[0]; + assert.ok(apiRequest.system, 'System prompt should be set'); + assert.strictEqual( + apiRequest.system, + 'You are a pirate. Respond like a pirate.' + ); + assert.strictEqual( + apiRequest.messages.length, + 1, + 'System message should not be in messages array' + ); + }); + + test('should return usage metadata', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Response', + usage: { + input_tokens: 100, + output_tokens: 50, + }, + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + }, + { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + assert.ok(response.usage, 'Usage should be returned'); + assert.strictEqual(response.usage?.inputTokens, 100); + assert.strictEqual(response.usage?.outputTokens, 50); + }); + + test('should handle different stop reasons', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'This is a partial response', + stopReason: 'max_tokens', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Tell me a story' }] }], + }, + { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + assert.ok(response, 'Response should be returned'); + assert.strictEqual(response.candidates[0].finishReason, 'length'); + }); + + test('should resolve model without anthropic prefix', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Response', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + // Resolve without prefix + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + assert.ok(modelAction, 'Model should be resolved without prefix'); + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hi' }] }], + }, + { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + assert.ok(response, 'Response should be returned'); + }); + + test('should resolve model with anthropic prefix', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Response', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + // Resolve with prefix + const modelAction = plugin.resolve( + 'model', + 'anthropic/claude-3-5-haiku-20241022' + ) as ModelAction; + assert.ok(modelAction, 'Model should be resolved with prefix'); + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hi' }] }], + }, + { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + assert.ok(response, 'Response should be returned'); + }); + + test('should handle unknown model names', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Response from future model', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + // Resolve unknown model (passes through to API) + const modelAction = plugin.resolve( + 'model', + 'claude-99-experimental-12345' + ) as ModelAction; + assert.ok(modelAction, 'Unknown model should still be resolved'); + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hi' }] }], + }, + { + streamingRequested: false, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + assert.ok(response, 'Response should be returned for unknown model'); + assert.strictEqual( + response.candidates[0].message.content[0].text, + 'Response from future model' + ); + }); +}); diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index 15b8d15aad..f5234250ba 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -15,13 +15,20 @@ */ import * as assert from 'assert'; -import { genkit } from 'genkit'; +import { genkit, type ActionMetadata } from 'genkit'; +import type { ModelInfo } from 'genkit/model'; import { describe, it } from 'node:test'; import anthropic from '../src/index.js'; import { KNOWN_CLAUDE_MODELS } from '../src/models.js'; import { PluginOptions, __testClient } from '../src/types.js'; import { createMockAnthropicClient } from './mocks/anthropic-client.js'; +function getModelInfo( + metadata: ActionMetadata | undefined +): ModelInfo | undefined { + return metadata?.metadata?.model as ModelInfo | undefined; +} + describe('Anthropic Plugin', () => { it('should register all supported Claude models', async () => { const mockClient = createMockAnthropicClient(); @@ -119,8 +126,14 @@ describe('Anthropic Plugin', () => { const mockClient = createMockAnthropicClient({ modelList: [ { id: 'claude-3-5-haiku-20241022', display_name: 'Claude 3.5 Haiku' }, + { + id: 'claude-3-5-haiku-latest', + display_name: 'Claude 3.5 Haiku Latest', + }, { id: 'claude-3-5-sonnet-20241022', display_name: 'Claude 3.5 Sonnet' }, { id: 'claude-sonnet-4-20250514', display_name: 'Claude 4 Sonnet' }, + { id: 'claude-new-5-20251212', display_name: 'Claude New 5' }, + { id: 'claude-experimental-latest' }, ], }); @@ -132,11 +145,72 @@ describe('Anthropic Plugin', () => { assert.ok(Array.isArray(models), 'Should return an array'); assert.ok(models.length > 0, 'Should return at least one model'); - // Verify model structure - for (const model of models) { - assert.ok(model.name, 'Model should have a name'); - // ActionMetadata has name and other properties, but kind is not required - } + const names = models.map((model) => model.name).sort(); + assert.ok( + names.includes('anthropic/claude-3-5-haiku'), + 'Known model should be listed once with normalized name' + ); + assert.strictEqual( + names.filter((name) => name === 'anthropic/claude-3-5-haiku').length, + 1, + 'Known model entries should be deduplicated' + ); + assert.ok( + names.includes('anthropic/claude-3-5-sonnet-20241022'), + 'Unknown Claude 3.5 Sonnet should be listed with full model ID' + ); + assert.ok( + names.includes('anthropic/claude-sonnet-4'), + 'Known Claude Sonnet 4 model should be listed' + ); + assert.ok( + names.includes('anthropic/claude-new-5-20251212'), + 'Unknown model IDs should surface as-is' + ); + assert.ok( + names.includes('anthropic/claude-experimental-latest'), + 'Latest-suffixed unknown models should be surfaced' + ); + + const haikuMetadata = models.find( + (model) => model.name === 'anthropic/claude-3-5-haiku' + ); + assert.ok(haikuMetadata, 'Haiku metadata should exist'); + const haikuInfo = getModelInfo(haikuMetadata); + assert.ok(haikuInfo, 'Haiku model info should exist'); + assert.ok( + haikuInfo?.versions?.includes('claude-3-5-haiku-20241022'), + 'Known versions should include dated identifier' + ); + assert.ok( + haikuInfo?.versions?.includes('claude-3-5-haiku-latest'), + 'Additional variants should be merged into versions' + ); + + const newModelMetadata = models.find( + (model) => model.name === 'anthropic/claude-new-5-20251212' + ); + const newModelInfo = getModelInfo(newModelMetadata); + assert.strictEqual( + newModelInfo?.label, + 'Claude New 5', + 'Unknown models should preserve display name as label' + ); + + const experimentalMetadata = models.find( + (model) => model.name === 'anthropic/claude-experimental-latest' + ); + const experimentalInfo = getModelInfo(experimentalMetadata); + assert.strictEqual( + experimentalInfo?.label, + 'Anthropic - claude-experimental', + 'Unknown latest variants should derive fallback label from normalized id' + ); + assert.deepStrictEqual( + experimentalInfo?.versions, + ['claude-experimental-latest'], + 'Unknown models should capture version identifiers' + ); // Verify mock was called const listStub = mockClient.models.list as any; diff --git a/js/plugins/anthropic/tests/streaming_test.ts b/js/plugins/anthropic/tests/streaming_test.ts new file mode 100644 index 0000000000..c8fac507a5 --- /dev/null +++ b/js/plugins/anthropic/tests/streaming_test.ts @@ -0,0 +1,384 @@ +/** + * 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 type { ModelAction } from '@genkit-ai/ai/model'; +import * as assert from 'assert'; +import { describe, mock, test } from 'node:test'; +import { anthropic } from '../src/index.js'; +import { __testClient } from '../src/types.js'; +import { + createMockAnthropicClient, + createMockAnthropicMessage, + mockContentBlockStart, + mockTextChunk, + mockToolUseChunk, +} from './mocks/anthropic-client.js'; + +describe('Streaming Integration Tests', () => { + test('should use streaming API when streamingRequested is true', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [ + mockContentBlockStart('Hello'), + mockTextChunk(' world'), + mockTextChunk('!'), + ], + messageResponse: createMockAnthropicMessage({ + text: 'Hello world!', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + output: { format: 'text' }, + }, + { + streamingRequested: true, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + // Verify final response + assert.ok(response, 'Response should be returned'); + assert.ok( + response.candidates[0].message.content[0].text, + 'Response should have text content' + ); + + // Since we can't control whether the runner chooses streaming or not from + // the plugin level, just verify we got a response + // The runner-level tests verify streaming behavior in detail + }); + + test('should handle streaming with multiple content blocks', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [ + mockContentBlockStart('First block'), + mockTextChunk(' continues'), + { + type: 'content_block_start', + index: 1, + content_block: { + type: 'text', + text: 'Second block', + }, + } as any, + { + type: 'content_block_delta', + index: 1, + delta: { + type: 'text_delta', + text: ' here', + }, + } as any, + ], + messageResponse: createMockAnthropicMessage({ + text: 'First block continues', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + output: { format: 'text' }, + }, + { + streamingRequested: true, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + // Verify response is returned even with multiple content blocks + assert.ok(response, 'Response should be returned'); + }); + + test('should handle streaming with tool use', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [ + mockToolUseChunk('toolu_123', 'get_weather', { city: 'NYC' }), + ], + messageResponse: createMockAnthropicMessage({ + toolUse: { + id: 'toolu_123', + name: 'get_weather', + input: { city: 'NYC' }, + }, + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Get NYC weather' }] }], + tools: [ + { + name: 'get_weather', + description: 'Get weather for a city', + inputSchema: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + required: ['city'], + }, + }, + ], + output: { format: 'text' }, + }, + { + streamingRequested: true, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + // Verify tool use in response + assert.ok(response.candidates[0].message.content[0].toolRequest); + assert.strictEqual( + response.candidates[0].message.content[0].toolRequest?.name, + 'get_weather' + ); + }); + + test('should handle abort signal', async () => { + const abortController = new AbortController(); + + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Hello world', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + // Abort before starting + abortController.abort(); + + // Test that abort signal is passed through + // The actual abort behavior is tested in runner tests + try { + await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + output: { format: 'text' }, + }, + { + streamingRequested: true, + sendChunk: mock.fn(), + abortSignal: abortController.signal, + } + ); + // If we get here, the mock doesn't fully simulate abort behavior, + // which is fine since runner tests cover this + } catch (error: any) { + // Expected abort error + assert.ok( + error.message.includes('Abort') || error.name === 'AbortError', + 'Should throw abort error' + ); + } + }); + + test('should handle errors during streaming', async () => { + const mockClient = createMockAnthropicClient({ + shouldError: new Error('API error'), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + try { + await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + output: { format: 'text' }, + }, + { + streamingRequested: true, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + assert.fail('Should have thrown an error'); + } catch (error: any) { + assert.strictEqual(error.message, 'API error'); + } + }); + + test('should handle empty response', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [], + messageResponse: createMockAnthropicMessage({ + text: '', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + output: { format: 'text' }, + }, + { + streamingRequested: true, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + assert.ok(response, 'Should return response even with empty content'); + }); + + test('should include usage metadata in streaming response', async () => { + const mockClient = createMockAnthropicClient({ + streamChunks: [mockContentBlockStart('Response'), mockTextChunk(' text')], + messageResponse: createMockAnthropicMessage({ + text: 'Response text', + usage: { + input_tokens: 50, + output_tokens: 25, + }, + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + output: { format: 'text' }, + }, + { + streamingRequested: true, + sendChunk: mock.fn(), + abortSignal: new AbortController().signal, + } + ); + + assert.ok(response.usage, 'Should include usage metadata'); + assert.strictEqual(response.usage?.inputTokens, 50); + assert.strictEqual(response.usage?.outputTokens, 25); + }); + + test('should not stream when streamingRequested is false', async () => { + const mockClient = createMockAnthropicClient({ + messageResponse: createMockAnthropicMessage({ + text: 'Non-streaming response', + }), + }); + + const plugin = anthropic({ + apiKey: 'test-key', + [__testClient]: mockClient, + }); + + const modelAction = plugin.resolve( + 'model', + 'claude-3-5-haiku-20241022' + ) as ModelAction; + + const streamingCallback = mock.fn(); + + const response = await modelAction( + { + messages: [{ role: 'user', content: [{ text: 'Hello' }] }], + }, + { + streamingRequested: false, + sendChunk: streamingCallback, + abortSignal: new AbortController().signal, + } + ); + + // Verify streaming callback was NOT called + assert.strictEqual( + streamingCallback.mock.calls.length, + 0, + 'Streaming callback should not be called' + ); + + // Verify non-streaming API was called + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + + // Verify stream API was NOT called + const streamStub = mockClient.messages.stream as any; + assert.strictEqual(streamStub.mock.calls.length, 0); + }); +}); diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index f6e6a17870..4551a57378 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -275,6 +275,9 @@ importers: '@types/node': specifier: ^20.11.16 version: 20.19.1 + check-node-version: + specifier: ^4.2.1 + version: 4.2.1 genkit: specifier: workspace:* version: link:../../genkit @@ -1048,6 +1051,9 @@ importers: specifier: workspace:* version: link:../../genkit devDependencies: + cross-env: + specifier: ^10.1.0 + version: 10.1.0 tsx: specifier: ^4.19.2 version: 4.20.3 @@ -4659,6 +4665,10 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -4670,6 +4680,11 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + check-node-version@4.2.1: + resolution: {integrity: sha512-YYmFYHV/X7kSJhuN/QYHUu998n/TRuDe8UenM3+m5NrkiH670lb9ILqHIvBencvJc4SDh+XcbXMR4b+TtubJiw==} + engines: {node: '>=8.3.0'} + hasBin: true + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -6379,6 +6394,9 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + map-values@1.0.1: + resolution: {integrity: sha512-BbShUnr5OartXJe1GeccAWtfro11hhgNJg6G9/UtWKjVGvV5U4C09cg5nk8JUevhXODaXY+hQ3xxMUKSs62ONQ==} + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -6612,6 +6630,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-filter@1.0.2: + resolution: {integrity: sha512-NahvP2vZcy1ZiiYah30CEPw0FpDcSkSePJBMpzl5EQgCmISijiGuJm3SPYp7U+Lf2TljyaIw3E5EgkEx/TNEVA==} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -6977,6 +6998,9 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -7090,6 +7114,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -10916,6 +10943,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10925,6 +10957,15 @@ snapshots: charenc@0.0.2: {} + check-node-version@4.2.1: + dependencies: + chalk: 3.0.0 + map-values: 1.0.1 + minimist: 1.2.8 + object-filter: 1.0.2 + run-parallel: 1.2.0 + semver: 6.3.1 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -13134,6 +13175,8 @@ snapshots: dependencies: tmpl: 1.0.5 + map-values@1.0.1: {} + markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -13345,6 +13388,8 @@ snapshots: object-assign@4.1.1: {} + object-filter@1.0.2: {} + object-hash@3.0.0: {} object-inspect@1.13.1: {} @@ -13706,6 +13751,8 @@ snapshots: dependencies: side-channel: 1.1.0 + queue-microtask@1.2.3: {} + range-parser@1.2.1: {} raw-body@2.5.2: @@ -13869,6 +13916,10 @@ snapshots: transitivePeerDependencies: - supports-color + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 diff --git a/js/testapps/anthropic-basic/package.json b/js/testapps/anthropic-basic/package.json index ae437730da..5026287bdc 100644 --- a/js/testapps/anthropic-basic/package.json +++ b/js/testapps/anthropic-basic/package.json @@ -9,7 +9,10 @@ "start:stable": "node lib/stable.js", "start:beta": "node lib/beta.js", "dev:stable": "genkit start -- npx tsx --watch src/stable.ts", - "dev:beta": "genkit start -- npx tsx --watch src/beta.ts" + "dev:beta": "genkit start -- npx tsx --watch src/beta.ts", + "genkit:dev": "cross-env GENKIT_ENV=dev npm run dev:stable", + "genkit:start": "cross-env GENKIT_ENV=dev genkit start -- tsx --watch src/stable.ts", + "dev": "export GENKIT_RUNTIME_ID=$(openssl rand -hex 8) && node lib/stable.js 2>&1" }, "keywords": [ "genkit", @@ -23,7 +26,8 @@ "@genkit-ai/anthropic": "workspace:*" }, "devDependencies": { - "typescript": "^5.6.2", - "tsx": "^4.19.2" + "cross-env": "^10.1.0", + "tsx": "^4.19.2", + "typescript": "^5.6.2" } } From c3a46589b902c5e3f2cf1dbb42ab4c26135f51d1 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 16:07:27 +0000 Subject: [PATCH 34/51] refactor(anthropic): extract out list function --- js/plugins/anthropic/src/index.ts | 107 +--------- js/plugins/anthropic/src/list.ts | 200 +++++++++++++++++++ js/plugins/anthropic/tests/streaming_test.ts | 78 +++----- 3 files changed, 234 insertions(+), 151 deletions(-) create mode 100644 js/plugins/anthropic/src/list.ts diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index c1450059a4..c1e501ccdb 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -16,20 +16,16 @@ */ import Anthropic from '@anthropic-ai/sdk'; -import { - genkitPluginV2, - modelActionMetadata, - type GenkitPluginV2, -} from 'genkit/plugin'; +import { genkitPluginV2, type GenkitPluginV2 } from 'genkit/plugin'; import { ActionMetadata, ModelReference, z } from 'genkit'; import { ModelAction } from 'genkit/model'; import { ActionType } from 'genkit/registry'; +import { listActions } from './list.js'; import { AnthropicConfigSchemaType, ClaudeConfig, ClaudeModelName, - GENERIC_CLAUDE_MODEL_INFO, KNOWN_CLAUDE_MODELS, KnownClaudeModels, claude35Haiku, @@ -42,12 +38,7 @@ import { claudeSonnet4, claudeSonnet45, } from './models.js'; -import { - AnthropicConfigSchema, - InternalPluginOptions, - PluginOptions, - __testClient, -} from './types.js'; +import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; export { claude35Haiku, @@ -59,96 +50,6 @@ export { claudeSonnet45, }; -function normalizeModelId(modelId: string): string { - return modelId.replace(/-(?:\d{8}|latest)$/i, ''); -} - -type ModelMetadataParams = Parameters[0]; - -async function list(client: Anthropic): Promise { - const clientModels = (await client.models.list()).data; - const metadataByName = new Map(); - const orderedNames: string[] = []; - - for (const modelInfo of clientModels) { - const modelId = modelInfo.id; - if (!modelId) { - continue; - } - - const normalizedId = normalizeModelId(modelId); - const ref = KNOWN_CLAUDE_MODELS[normalizedId]; - - if (ref) { - const existing = metadataByName.get(ref.name); - const baseInfo = existing?.info ?? ref.info; - const mergedVersions = new Set( - baseInfo?.versions ?? ref.info?.versions ?? [] - ); - mergedVersions.add(modelId); - - const info = { - ...baseInfo, - versions: Array.from(mergedVersions), - }; - - metadataByName.set(ref.name, { - name: ref.name, - info, - configSchema: ref.configSchema, - }); - - if (!existing) { - orderedNames.push(ref.name); - } - continue; - } - - const fallbackName = `anthropic/${modelId}`; - const existingFallback = metadataByName.get(fallbackName); - const fallbackLabel = - modelInfo.display_name ?? - (normalizedId !== modelId - ? `Anthropic - ${normalizedId}` - : `Anthropic - ${modelId}`); - - if (existingFallback) { - const info = { - ...existingFallback.info, - versions: existingFallback.info?.versions - ? Array.from( - new Set([...(existingFallback.info.versions ?? []), modelId]) - ) - : [modelId], - }; - metadataByName.set(fallbackName, { - ...existingFallback, - info, - }); - continue; - } - - metadataByName.set(fallbackName, { - name: fallbackName, - info: { - ...GENERIC_CLAUDE_MODEL_INFO, - label: fallbackLabel, - versions: modelId ? [modelId] : [...GENERIC_CLAUDE_MODEL_INFO.versions], - supports: { - ...GENERIC_CLAUDE_MODEL_INFO.supports, - output: [...GENERIC_CLAUDE_MODEL_INFO.supports.output], - }, - }, - configSchema: AnthropicConfigSchema, - }); - orderedNames.push(fallbackName); - } - - return orderedNames.map((name) => - modelActionMetadata(metadataByName.get(name)!) - ); -} - /** * Gets or creates an Anthropic client instance. * Supports test client injection for internal testing. @@ -237,7 +138,7 @@ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { }, list: async () => { if (listActionsCache) return listActionsCache; - listActionsCache = await list(client); + listActionsCache = await listActions(client); return listActionsCache; }, }); diff --git a/js/plugins/anthropic/src/list.ts b/js/plugins/anthropic/src/list.ts new file mode 100644 index 0000000000..a3ba7be1e2 --- /dev/null +++ b/js/plugins/anthropic/src/list.ts @@ -0,0 +1,200 @@ +/** + * Original work Copyright 2024 Bloom Labs Inc + * Modifications Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import { modelActionMetadata } from 'genkit/plugin'; + +import { ActionMetadata, ModelReference, z } from 'genkit'; +import { + GENERIC_CLAUDE_MODEL_INFO, + KNOWN_CLAUDE_MODELS, + claude35Haiku, + claude3Haiku, + claudeHaiku45, + claudeOpus4, + claudeOpus41, + claudeSonnet4, + claudeSonnet45, +} from './models.js'; +import { AnthropicConfigSchema } from './types.js'; + +export { + claude35Haiku, + claude3Haiku, + claudeHaiku45, + claudeOpus4, + claudeOpus41, + claudeSonnet4, + claudeSonnet45, +}; + +function normalizeModelId(modelId: string): string { + // Strip date suffixes (e.g. "-20241001") or "-latest" so lookups hit canonical keys. + return modelId.replace(/-(?:\d{8}|latest)$/i, ''); +} + +type ModelMetadataParams = Parameters[0]; + +interface MergeKnownModelMetadataParams { + modelId: string; + ref: ModelReference; + metadataByName: Map; + orderedNames: string[]; +} +/** + * Integrates metadata for a known Claude model into the aggregated list. + * + * It merges version information collected from the Anthropic API onto the + * canonical model definition while preserving any additional metadata that + * may already exist in the accumulator. + */ +function mergeKnownModelMetadata({ + modelId, + ref, + metadataByName, + orderedNames, +}: MergeKnownModelMetadataParams): void { + // Merge onto any prior metadata we have for this named model. + const existing = metadataByName.get(ref.name); + const priorInfo = existing?.info ?? ref.info ?? {}; + const priorVersions = Array.isArray(priorInfo.versions) + ? priorInfo.versions + : (ref.info?.versions ?? []); + + // Track every concrete model ID surfaced by the API so they appear as selectable versions. + const versions = new Set(priorVersions as string[]); + versions.add(modelId); + + metadataByName.set(ref.name, { + name: ref.name, + info: { + ...priorInfo, + versions: Array.from(versions), + }, + configSchema: ref.configSchema, + }); + + if (!existing) { + // Preserve the discovery order for deterministic iteration. + orderedNames.push(ref.name); + } +} + +interface MergeFallbackModelMetadataParams { + modelId: string; + normalizedId: string; + displayName?: string; + metadataByName: Map; + orderedNames: string[]; +} + +/** + * Creates or updates metadata entries for Anthropic models that are not + * explicitly enumerated in `KNOWN_CLAUDE_MODELS`. + * + * The resulting metadata uses a generic Claude descriptor while capturing + * the specific model ID returned by the API so it can be surfaced in the + * Genkit UI. + */ +function mergeFallbackModelMetadata({ + modelId, + normalizedId, + displayName, + metadataByName, + orderedNames, +}: MergeFallbackModelMetadataParams): void { + const fallbackName = `anthropic/${modelId}`; + const existing = metadataByName.get(fallbackName); + const fallbackLabel = + displayName ?? + (normalizedId !== modelId + ? `Anthropic - ${normalizedId}` + : `Anthropic - ${modelId}`); + + if (existing) { + const priorVersions = existing.info?.versions ?? []; + const versions = new Set( + Array.isArray(priorVersions) ? priorVersions : [] + ); + versions.add(modelId); + + metadataByName.set(fallbackName, { + ...existing, + info: { + ...existing.info, + versions: Array.from(versions), + }, + }); + return; + } + + metadataByName.set(fallbackName, { + name: fallbackName, + info: { + ...GENERIC_CLAUDE_MODEL_INFO, + label: fallbackLabel, + versions: modelId ? [modelId] : [...GENERIC_CLAUDE_MODEL_INFO.versions], + supports: { + ...GENERIC_CLAUDE_MODEL_INFO.supports, + output: [...GENERIC_CLAUDE_MODEL_INFO.supports.output], + }, + }, + configSchema: AnthropicConfigSchema, + }); + orderedNames.push(fallbackName); +} + +export async function listActions( + client: Anthropic +): Promise { + const clientModels = (await client.models.list()).data; + const metadataByName = new Map(); + const orderedNames: string[] = []; + + for (const modelInfo of clientModels) { + const modelId = modelInfo.id; + if (!modelId) { + continue; + } + + const normalizedId = normalizeModelId(modelId); + const ref = KNOWN_CLAUDE_MODELS[normalizedId]; + + if (ref) { + mergeKnownModelMetadata({ + modelId, + ref, + metadataByName, + orderedNames, + }); + continue; + } + + // For models we don't explicitly track, synthesize a generic entry that still surfaces the ID. + mergeFallbackModelMetadata({ + modelId, + normalizedId, + displayName: modelInfo.display_name ?? undefined, + metadataByName, + orderedNames, + }); + } + + return orderedNames.map((name) => + modelActionMetadata(metadataByName.get(name)!) + ); +} diff --git a/js/plugins/anthropic/tests/streaming_test.ts b/js/plugins/anthropic/tests/streaming_test.ts index c8fac507a5..84d45e0d9a 100644 --- a/js/plugins/anthropic/tests/streaming_test.ts +++ b/js/plugins/anthropic/tests/streaming_test.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -import type { ModelAction } from '@genkit-ai/ai/model'; import * as assert from 'assert'; +import type { ModelAction } from 'genkit/model'; import { describe, mock, test } from 'node:test'; import { anthropic } from '../src/index.js'; -import { __testClient } from '../src/types.js'; +import { PluginOptions, __testClient } from '../src/types.js'; import { createMockAnthropicClient, createMockAnthropicMessage, @@ -28,7 +28,7 @@ import { } from './mocks/anthropic-client.js'; describe('Streaming Integration Tests', () => { - test('should use streaming API when streamingRequested is true', async () => { + test('should use streaming API when onChunk is provided', async () => { const mockClient = createMockAnthropicClient({ streamChunks: [ mockContentBlockStart('Hello'), @@ -43,9 +43,9 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; @@ -56,8 +56,7 @@ describe('Streaming Integration Tests', () => { output: { format: 'text' }, }, { - streamingRequested: true, - sendChunk: mock.fn(), + onChunk: mock.fn() as any, abortSignal: new AbortController().signal, } ); @@ -65,7 +64,7 @@ describe('Streaming Integration Tests', () => { // Verify final response assert.ok(response, 'Response should be returned'); assert.ok( - response.candidates[0].message.content[0].text, + response.candidates?.[0]?.message.content[0].text, 'Response should have text content' ); @@ -104,9 +103,9 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; @@ -117,8 +116,7 @@ describe('Streaming Integration Tests', () => { output: { format: 'text' }, }, { - streamingRequested: true, - sendChunk: mock.fn(), + onChunk: mock.fn() as any, abortSignal: new AbortController().signal, } ); @@ -144,9 +142,9 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; @@ -170,14 +168,13 @@ describe('Streaming Integration Tests', () => { output: { format: 'text' }, }, { - streamingRequested: true, - sendChunk: mock.fn(), + onChunk: mock.fn() as any, abortSignal: new AbortController().signal, } ); // Verify tool use in response - assert.ok(response.candidates[0].message.content[0].toolRequest); + assert.ok(response.candidates?.[0]?.message.content[0].toolRequest); assert.strictEqual( response.candidates[0].message.content[0].toolRequest?.name, 'get_weather' @@ -196,9 +193,9 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; @@ -215,8 +212,7 @@ describe('Streaming Integration Tests', () => { output: { format: 'text' }, }, { - streamingRequested: true, - sendChunk: mock.fn(), + onChunk: mock.fn() as any, abortSignal: abortController.signal, } ); @@ -239,9 +235,9 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; @@ -253,8 +249,7 @@ describe('Streaming Integration Tests', () => { output: { format: 'text' }, }, { - streamingRequested: true, - sendChunk: mock.fn(), + onChunk: mock.fn() as any, abortSignal: new AbortController().signal, } ); @@ -275,9 +270,9 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; @@ -288,8 +283,7 @@ describe('Streaming Integration Tests', () => { output: { format: 'text' }, }, { - streamingRequested: true, - sendChunk: mock.fn(), + onChunk: mock.fn() as any, abortSignal: new AbortController().signal, } ); @@ -312,9 +306,9 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; @@ -325,8 +319,7 @@ describe('Streaming Integration Tests', () => { output: { format: 'text' }, }, { - streamingRequested: true, - sendChunk: mock.fn(), + onChunk: mock.fn() as any, abortSignal: new AbortController().signal, } ); @@ -336,7 +329,7 @@ describe('Streaming Integration Tests', () => { assert.strictEqual(response.usage?.outputTokens, 25); }); - test('should not stream when streamingRequested is false', async () => { + test('should not stream when onChunk is not provided', async () => { const mockClient = createMockAnthropicClient({ messageResponse: createMockAnthropicMessage({ text: 'Non-streaming response', @@ -346,33 +339,22 @@ describe('Streaming Integration Tests', () => { const plugin = anthropic({ apiKey: 'test-key', [__testClient]: mockClient, - }); + } as PluginOptions); - const modelAction = plugin.resolve( + const modelAction = plugin.resolve!( 'model', 'claude-3-5-haiku-20241022' ) as ModelAction; - const streamingCallback = mock.fn(); - - const response = await modelAction( + await modelAction( { messages: [{ role: 'user', content: [{ text: 'Hello' }] }], }, { - streamingRequested: false, - sendChunk: streamingCallback, abortSignal: new AbortController().signal, } ); - // Verify streaming callback was NOT called - assert.strictEqual( - streamingCallback.mock.calls.length, - 0, - 'Streaming callback should not be called' - ); - // Verify non-streaming API was called const createStub = mockClient.messages.create as any; assert.strictEqual(createStub.mock.calls.length, 1); From 88089d9e3565928c9234e7d60f67c4ab608d217d Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 16:12:11 +0000 Subject: [PATCH 35/51] refactor(anthropic): clean up list code --- js/plugins/anthropic/src/list.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/js/plugins/anthropic/src/list.ts b/js/plugins/anthropic/src/list.ts index a3ba7be1e2..e2a1cdfcdc 100644 --- a/js/plugins/anthropic/src/list.ts +++ b/js/plugins/anthropic/src/list.ts @@ -76,7 +76,7 @@ function mergeKnownModelMetadata({ : (ref.info?.versions ?? []); // Track every concrete model ID surfaced by the API so they appear as selectable versions. - const versions = new Set(priorVersions as string[]); + const versions = new Set(priorVersions); versions.add(modelId); metadataByName.set(ref.name, { @@ -89,7 +89,7 @@ function mergeKnownModelMetadata({ }); if (!existing) { - // Preserve the discovery order for deterministic iteration. + // Preserve the discovery order for determinism. orderedNames.push(ref.name); } } @@ -121,9 +121,7 @@ function mergeFallbackModelMetadata({ const existing = metadataByName.get(fallbackName); const fallbackLabel = displayName ?? - (normalizedId !== modelId - ? `Anthropic - ${normalizedId}` - : `Anthropic - ${modelId}`); + `Anthropic - ${normalizedId !== modelId ? normalizedId : modelId}`; if (existing) { const priorVersions = existing.info?.versions ?? []; @@ -158,6 +156,16 @@ function mergeFallbackModelMetadata({ orderedNames.push(fallbackName); } +/** + * Retrieves available Anthropic models from the API and converts them into Genkit action metadata. + * + * This function queries the Anthropic API for the list of available models, matches them against + * known Claude models, and generates metadata for both known and unknown models. The resulting + * metadata includes version information and configuration schemas suitable for use in Genkit. + * + * @param client - The Anthropic API client instance + * @returns A promise that resolves to an array of action metadata for all discovered models + */ export async function listActions( client: Anthropic ): Promise { @@ -194,7 +202,11 @@ export async function listActions( }); } - return orderedNames.map((name) => - modelActionMetadata(metadataByName.get(name)!) - ); + return orderedNames.map((name) => { + const metadata = metadataByName.get(name); + if (!metadata) { + throw new Error(`Missing metadata for model: ${name}`); + } + return modelActionMetadata(metadata); + }); } From aadc1500ae986d7247e5aaf36ea3c371f96ec464 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 16:32:18 +0000 Subject: [PATCH 36/51] docs(anthropic): update README --- js/plugins/anthropic/README.md | 62 +++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index e3621424db..70f3a1e30f 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -4,25 +4,11 @@

Anthropic AI plugin for Google Firebase Genkit

-
- Github lerna version - NPM Downloads - GitHub Org's stars - GitHub License - Static Badge -
- -
- GitHub Issues or Pull Requests - GitHub Issues or Pull Requests - GitHub commit activity -
- `@genkit-ai/anthropic` is the official Anthropic plugin for [Firebase Genkit](https://github.com/firebase/genkit). It supersedes the earlier community package `genkitx-anthropic` and is now maintained by Google. ## Supported models -The plugin supports the most recent Anthropic models: **Claude 3.7 Sonnet**, **Claude 3.5 Sonnet**, **Claude 3 Opus**, **Claude 3 Sonnet**, and **Claude 3 Haiku**. +The plugin supports the most recent Anthropic models: **Claude Sonnet 4.5**, **Claude Opus 4.1**, **Claude Haiku 4.5**, **Claude Sonnet 4**, **Claude Opus 4**, **Claude 3.5 Haiku**, and **Claude 3 Haiku**. ## Installation @@ -101,6 +87,51 @@ export const jokeFlow = ai.defineFlow( ); ``` +### Direct model usage (without Genkit instance) + +The plugin supports Genkit Plugin API v2, which allows you to use models directly without initializing the full Genkit framework: + +```typescript +import { anthropic } from '@genkit-ai/anthropic'; + +// Create a model reference directly +const claude = anthropic.model('claude-sonnet-4.5'); + +// Use the model directly +const response = await claude({ + messages: [ + { + role: 'user', + content: [{ text: 'Tell me a joke.' }], + }, + ], +}); + +console.log(response); +``` + +You can also import pre-configured model references: + +```typescript +import { claudeSonnet45, claudeOpus41, claude35Haiku } from '@genkit-ai/anthropic'; + +// Use the model reference directly +const response = await claudeSonnet45({ + messages: [ + { + role: 'user', + content: [{ text: 'Hello!' }], + }, + ], +}); +``` + +This approach is useful for: + +- Framework developers who need raw model access +- Testing models in isolation +- Using Genkit models in non-Genkit applications + ## Acknowledgements This plugin builds on the community work published as [`genkitx-anthropic`](https://github.com/BloomLabsInc/genkit-plugins/blob/main/plugins/anthropic/README.md) by Bloom Labs Inc. Their Apache 2.0–licensed implementation provided the foundation for this maintained package. @@ -114,7 +145,6 @@ Want to contribute to the project? That's awesome! Head over to our [Contributio > [!NOTE] > This repository depends on Google's Firebase Genkit. For issues and questions related to Genkit, please refer to instructions available in [Genkit's repository](https://github.com/firebase/genkit). -Reach out by opening a discussion on [GitHub Discussions](https://github.com/BloomLabsInc/genkitx-openai/discussions). ## Credits From 455899ebceb0d8c840608a9e06ca287bb556be5b Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 16:51:08 +0000 Subject: [PATCH 37/51] fix(anthropic): correct model ids/refs/names --- js/plugins/anthropic/src/index.ts | 2 +- js/plugins/anthropic/src/models.ts | 24 +++++++++++------------ js/testapps/anthropic-basic/src/stable.ts | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index c1e501ccdb..f20f30c500 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -69,7 +69,7 @@ function getAnthropicClient(options?: PluginOptions): Anthropic { ); } const defaultHeaders: Record = {}; - if (options?.cacheSystemPrompt == true) { + if (options?.cacheSystemPrompt) { defaultHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31'; } return new Anthropic({ apiKey, defaultHeaders }); diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 5f329e8dfd..1c773401db 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -93,7 +93,7 @@ export const claude35Haiku = modelRef({ name: 'claude-3-5-haiku', namespace: 'anthropic', info: { - versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku-latest'], + versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku'], label: 'Anthropic - Claude 3.5 Haiku', supports: { multiturn: true, @@ -108,10 +108,10 @@ export const claude35Haiku = modelRef({ }); export const claudeSonnet45 = modelRef({ - name: 'claude-sonnet-4.5', + name: 'claude-sonnet-4-5', namespace: 'anthropic', info: { - versions: ['claude-sonnet-4.5-20250929', 'claude-sonnet-4.5-latest'], + versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5'], label: 'Anthropic - Claude Sonnet 4.5', supports: { multiturn: true, @@ -122,14 +122,14 @@ export const claudeSonnet45 = modelRef({ }, }, configSchema: AnthropicConfigSchema, - version: 'claude-sonnet-4.5-latest', + version: 'claude-sonnet-4-5', }); export const claudeHaiku45 = modelRef({ - name: 'claude-haiku-4.5', + name: 'claude-haiku-4-5', namespace: 'anthropic', info: { - versions: ['claude-haiku-4.5-20251001', 'claude-haiku-4.5-latest'], + versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5'], label: 'Anthropic - Claude Haiku 4.5', supports: { multiturn: true, @@ -140,14 +140,14 @@ export const claudeHaiku45 = modelRef({ }, }, configSchema: AnthropicConfigSchema, - version: 'claude-haiku-4.5-latest', + version: 'claude-haiku-4-5', }); export const claudeOpus41 = modelRef({ name: 'claude-opus-4.1', namespace: 'anthropic', info: { - versions: ['claude-opus-4.1-20250805', 'claude-opus-4.1-latest'], + versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1'], label: 'Anthropic - Claude Opus 4.1', supports: { multiturn: true, @@ -158,7 +158,7 @@ export const claudeOpus41 = modelRef({ }, }, configSchema: AnthropicConfigSchema, - version: 'claude-opus-4.1-latest', + version: 'claude-opus-4-1', }); export const KNOWN_CLAUDE_MODELS: Record< @@ -169,9 +169,9 @@ export const KNOWN_CLAUDE_MODELS: Record< 'claude-3-5-haiku': claude35Haiku, 'claude-sonnet-4': claudeSonnet4, 'claude-opus-4': claudeOpus4, - 'claude-sonnet-4.5': claudeSonnet45, - 'claude-haiku-4.5': claudeHaiku45, - 'claude-opus-4.1': claudeOpus41, + 'claude-sonnet-4-5': claudeSonnet45, + 'claude-haiku-4-5': claudeHaiku45, + 'claude-opus-4-1': claudeOpus41, }; /** diff --git a/js/testapps/anthropic-basic/src/stable.ts b/js/testapps/anthropic-basic/src/stable.ts index 5ca6236adb..9390a86ea6 100644 --- a/js/testapps/anthropic-basic/src/stable.ts +++ b/js/testapps/anthropic-basic/src/stable.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { anthropic } from '@genkit-ai/anthropic'; +import { anthropic, claudeSonnet45 } from '@genkit-ai/anthropic'; import { genkit } from 'genkit'; const ai = genkit({ @@ -26,7 +26,7 @@ const ai = genkit({ ai.defineFlow('anthropic-stable-hello', async () => { const { text } = await ai.generate({ - model: anthropic.model('claude-3-5-haiku'), + model: claudeSonnet45, prompt: 'You are a friendly Claude assistant. Greet the user briefly.', }); @@ -35,7 +35,7 @@ ai.defineFlow('anthropic-stable-hello', async () => { ai.defineFlow('anthropic-stable-stream', async (_, { sendChunk }) => { const { stream } = ai.generateStream({ - model: anthropic.model('claude-3-5-haiku'), + model: claudeSonnet45, prompt: 'Compose a short limerick about using Genkit with Anthropic.', }); From a57c03a9e4c0cb3ba1d60a050cf344009e2c1358 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 16:57:14 +0000 Subject: [PATCH 38/51] chore(anthropic): remove old link in README --- js/plugins/anthropic/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 70f3a1e30f..84807e26ce 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -1,4 +1,4 @@ -![Firebase Genkit + Anthropic AI](https://github.com/BloomLabsInc/genkit-plugins/blob/main/assets/genkit-anthropic.png?raw=true) +# Firebase Genkit + Anthropic AI

Firebase Genkit <> Anthropic AI Plugin

From dfae6705068d474551652aea82aa98a82967fbcd Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 17:00:04 +0000 Subject: [PATCH 39/51] refactor(anthropic): extract magic string --- js/plugins/anthropic/src/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index f20f30c500..f23d41ed60 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -40,6 +40,8 @@ import { } from './models.js'; import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; +const PROMPT_CACHING_BETA_HEADER_VALUE = 'prompt-caching-2024-07-31'; + export { claude35Haiku, claude3Haiku, @@ -70,7 +72,7 @@ function getAnthropicClient(options?: PluginOptions): Anthropic { } const defaultHeaders: Record = {}; if (options?.cacheSystemPrompt) { - defaultHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31'; + defaultHeaders['anthropic-beta'] = PROMPT_CACHING_BETA_HEADER_VALUE; } return new Anthropic({ apiKey, defaultHeaders }); } From 509128c8810126de961fcddc731719a26997d6c0 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Tue, 11 Nov 2025 17:20:57 +0000 Subject: [PATCH 40/51] fix(js/plugins/anthropic): pnpm install no frozen lockfile --- js/pnpm-lock.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 4551a57378..504e8571b5 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -178,19 +178,10 @@ importers: typescript: specifier: ^4.9.0 version: 4.9.5 -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> 2b79f7091 (refactor(anthropic): add basic testapp and remove nested ternary) optionalDependencies: '@genkit-ai/firebase': specifier: ^1.16.1 version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) -<<<<<<< HEAD -======= ->>>>>>> 6f4fc016b (refactor(anthropic): a lot of refactoring and add beta api) -======= ->>>>>>> 2b79f7091 (refactor(anthropic): add basic testapp and remove nested ternary) doc-snippets: dependencies: From 232531165fe831d1ef0b11825417e8cdebda87e4 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 17 Nov 2025 14:25:23 +0000 Subject: [PATCH 41/51] feat(js/plugins/anthropic): handle extended thinking requests in configs --- js/plugins/anthropic/README.md | 21 ++ js/plugins/anthropic/src/models.ts | 88 +++++--- js/plugins/anthropic/src/runner/base.ts | 68 ++++++ js/plugins/anthropic/src/runner/beta.ts | 51 ++++- js/plugins/anthropic/src/runner/stable.ts | 55 ++++- js/plugins/anthropic/src/types.ts | 34 ++- .../anthropic/tests/beta_runner_test.ts | 29 ++- .../anthropic/tests/integration_test.ts | 131 ++++++++++++ .../anthropic/tests/stable_runner_test.ts | 193 ++++++++++++------ 9 files changed, 567 insertions(+), 103 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 84807e26ce..6aa02f9d53 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -65,6 +65,27 @@ const response = await ai.generate({ console.log(response.text); ``` +### Extended thinking + +Claude 4 models can expose their internal reasoning. Enable it per-request with the Anthropic thinking config and read the reasoning from the response: + +```typescript +const response = await ai.generate({ + prompt: 'Walk me through your reasoning for Fermat’s little theorem.', + config: { + thinking: { + enabled: true, + budgetTokens: 4096, // Must be >= 1024 and less than max_tokens + }, + }, +}); + +console.log(response.text); // Final assistant answer +console.log(response.reasoning); // Summarized thinking steps +``` + +When thinking is enabled, request bodies sent through the plugin include the `thinking` payload (`{ type: 'enabled', budget_tokens: … }`) that Anthropic’s API expects, and streamed responses deliver `reasoning` parts as they arrive so you can render the chain-of-thought incrementally. + ### Within a flow ```typescript diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 1c773401db..4b848cebe2 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -29,13 +29,16 @@ import { model } from 'genkit/plugin'; import { BetaRunner, Runner } from './runner/index.js'; import { + AnthropicBaseConfigSchema, + AnthropicBaseConfigSchemaType, AnthropicConfigSchema, + AnthropicThinkingConfigSchema, resolveBetaEnabled, type ClaudeModelParams, type ClaudeRunnerParams, } from './types.js'; -export const claudeSonnet4 = modelRef({ +export const claudeSonnet4 = modelRef({ name: 'claude-sonnet-4', namespace: 'anthropic', info: { @@ -49,11 +52,11 @@ export const claudeSonnet4 = modelRef({ output: ['text'], }, }, - configSchema: AnthropicConfigSchema, + configSchema: AnthropicThinkingConfigSchema, version: 'claude-sonnet-4-20250514', }); -export const claude3Haiku = modelRef({ +export const claude3Haiku = modelRef({ name: 'claude-3-haiku', namespace: 'anthropic', info: { @@ -67,11 +70,11 @@ export const claude3Haiku = modelRef({ output: ['text'], }, }, - configSchema: AnthropicConfigSchema, + configSchema: AnthropicBaseConfigSchema, version: 'claude-3-haiku-20240307', }); -export const claudeOpus4 = modelRef({ +export const claudeOpus4 = modelRef({ name: 'claude-opus-4', namespace: 'anthropic', info: { @@ -85,11 +88,11 @@ export const claudeOpus4 = modelRef({ output: ['text'], }, }, - configSchema: AnthropicConfigSchema, + configSchema: AnthropicThinkingConfigSchema, version: 'claude-opus-4-20250514', }); -export const claude35Haiku = modelRef({ +export const claude35Haiku = modelRef({ name: 'claude-3-5-haiku', namespace: 'anthropic', info: { @@ -103,11 +106,11 @@ export const claude35Haiku = modelRef({ output: ['text'], }, }, - configSchema: AnthropicConfigSchema, + configSchema: AnthropicBaseConfigSchema, version: 'claude-3-5-haiku-latest', }); -export const claudeSonnet45 = modelRef({ +export const claudeSonnet45 = modelRef({ name: 'claude-sonnet-4-5', namespace: 'anthropic', info: { @@ -121,11 +124,11 @@ export const claudeSonnet45 = modelRef({ output: ['text'], }, }, - configSchema: AnthropicConfigSchema, + configSchema: AnthropicThinkingConfigSchema, version: 'claude-sonnet-4-5', }); -export const claudeHaiku45 = modelRef({ +export const claudeHaiku45 = modelRef({ name: 'claude-haiku-4-5', namespace: 'anthropic', info: { @@ -139,12 +142,12 @@ export const claudeHaiku45 = modelRef({ output: ['text'], }, }, - configSchema: AnthropicConfigSchema, + configSchema: AnthropicThinkingConfigSchema, version: 'claude-haiku-4-5', }); -export const claudeOpus41 = modelRef({ - name: 'claude-opus-4.1', +export const claudeOpus41 = modelRef({ + name: 'claude-opus-4-1', namespace: 'anthropic', info: { versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1'], @@ -157,13 +160,15 @@ export const claudeOpus41 = modelRef({ output: ['text'], }, }, - configSchema: AnthropicConfigSchema, + configSchema: AnthropicThinkingConfigSchema, version: 'claude-opus-4-1', }); export const KNOWN_CLAUDE_MODELS: Record< string, - ModelReference + ModelReference< + AnthropicBaseConfigSchemaType | AnthropicThinkingConfigSchemaType + > > = { 'claude-3-haiku': claude3Haiku, 'claude-3-5-haiku': claude35Haiku, @@ -193,14 +198,20 @@ export const GENERIC_CLAUDE_MODEL_INFO = { export type KnownClaudeModels = keyof typeof KNOWN_CLAUDE_MODELS; export type ClaudeModelName = string; export type AnthropicConfigSchemaType = typeof AnthropicConfigSchema; +export type AnthropicThinkingConfigSchemaType = + typeof AnthropicThinkingConfigSchema; export type ClaudeConfig = z.infer; /** * Creates the runner used by Genkit to interact with the Claude model. * @param params Configuration for the Claude runner. + * @param configSchema The config schema for this model (used for type inference). * @returns The runner that Genkit will call when the model is invoked. */ -export function claudeRunner(params: ClaudeRunnerParams) { +export function claudeRunner( + params: ClaudeRunnerParams, + configSchema: TConfigSchema +) { const { defaultApiVersion, ...runnerParams } = params; if (!runnerParams.client) { @@ -211,7 +222,7 @@ export function claudeRunner(params: ClaudeRunnerParams) { let betaRunner: BetaRunner | null = null; return async ( - request: GenerateRequest, + request: GenerateRequest, { streamingRequested, sendChunk, @@ -222,11 +233,22 @@ export function claudeRunner(params: ClaudeRunnerParams) { abortSignal: AbortSignal; } ): Promise => { - const isBeta = resolveBetaEnabled(request.config, defaultApiVersion); + // Cast to AnthropicConfigSchema for internal runner which expects the full schema + const normalizedRequest = request as unknown as GenerateRequest< + typeof AnthropicConfigSchema + >; + const isBeta = resolveBetaEnabled( + normalizedRequest.config, + defaultApiVersion + ); const runner = isBeta ? (betaRunner ??= new BetaRunner(runnerParams)) : (stableRunner ??= new Runner(runnerParams)); - return runner.run(request, { streamingRequested, sendChunk, abortSignal }); + return runner.run(normalizedRequest, { + streamingRequested, + sendChunk, + abortSignal, + }); }; } @@ -237,7 +259,7 @@ export function claudeRunner(params: ClaudeRunnerParams) { export function claudeModelReference( name: string, config?: z.infer -): ModelReference { +): ModelReference { const knownModel = KNOWN_CLAUDE_MODELS[name]; if (knownModel) { return modelRef({ @@ -268,7 +290,7 @@ export function claudeModel( client?: Anthropic, cacheSystemPrompt?: boolean, defaultApiVersion?: 'stable' | 'beta' -): ModelAction { +): ModelAction { const params = typeof paramsOrName === 'string' ? { @@ -294,18 +316,24 @@ export function claudeModel( // Use supported model ref if available, otherwise create generic model ref const modelRef = KNOWN_CLAUDE_MODELS[name]; const modelInfo = modelRef ? modelRef.info : GENERIC_CLAUDE_MODEL_INFO; + const configSchema = modelRef?.configSchema ?? AnthropicConfigSchema; - return model( + return model< + AnthropicBaseConfigSchemaType | AnthropicThinkingConfigSchemaType + >( { name: `anthropic/${name}`, ...modelInfo, - configSchema: AnthropicConfigSchema, + configSchema: configSchema, }, - claudeRunner({ - name, - client: runnerClient, - cacheSystemPrompt: cachePrompt, - defaultApiVersion: apiVersion, - }) + claudeRunner( + { + name, + client: runnerClient, + cacheSystemPrompt: cachePrompt, + defaultApiVersion: apiVersion, + }, + configSchema + ) ); } diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index b1d1ffd992..8ce7ea1353 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -34,6 +34,7 @@ import { MediaType, MediaTypeSchema, type ClaudeRunnerParams, + type ThinkingConfig, } from '../types.js'; import { @@ -49,6 +50,8 @@ import { RunnerTypes, } from './types.js'; +const ANTHROPIC_THINKING_CUSTOM_KEY = 'anthropicThinking'; + /** * Shared runner logic for Anthropic SDK integrations. * @@ -256,6 +259,71 @@ 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; + } + + protected getRedactedThinkingData(part: Part): string | undefined { + const custom = part.custom as Record | undefined; + const redacted = custom?.redactedThinking; + return typeof redacted === 'string' ? redacted : undefined; + } + + protected toAnthropicThinkingConfig( + config: ThinkingConfig | undefined + ): + | { type: 'enabled'; budget_tokens: number } + | { type: 'disabled' } + | undefined { + if (!config) return undefined; + + const { enabled, budgetTokens } = config; + + if (enabled === true) { + if (budgetTokens === undefined) { + return undefined; + } + return { type: 'enabled', budget_tokens: budgetTokens }; + } + + if (enabled === false) { + return { type: 'disabled' }; + } + + if (budgetTokens !== undefined) { + return { type: 'enabled', budget_tokens: budgetTokens }; + } + + return undefined; + } + protected toWebSearchToolResultPart(params: { toolUseId: string; content: unknown; diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 1e67eb3ada..0e48381f06 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -24,9 +24,11 @@ import type { MessageCreateParamsStreaming as BetaMessageCreateParamsStreaming, BetaMessageParam, BetaRawMessageStreamEvent, + BetaRedactedThinkingBlockParam, BetaRequestDocumentBlock, BetaStopReason, BetaTextBlockParam, + BetaThinkingBlockParam, BetaTool, BetaToolResultBlockParam, BetaToolUseBlockParam, @@ -80,7 +82,9 @@ interface BetaRunnerTypes extends RunnerTypes { | BetaImageBlockParam | BetaRequestDocumentBlock | BetaToolUseBlockParam - | BetaToolResultBlockParam; + | BetaToolResultBlockParam + | BetaThinkingBlockParam + | BetaRedactedThinkingBlockParam; } /** @@ -103,7 +107,31 @@ export class BetaRunner extends BaseRunner { | BetaImageBlockParam | BetaRequestDocumentBlock | BetaToolUseBlockParam - | BetaToolResultBlockParam { + | BetaToolResultBlockParam + | BetaThinkingBlockParam + | BetaRedactedThinkingBlockParam { + if (part.reasoning) { + 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.' + ); + } + return { + type: 'thinking', + thinking: part.reasoning, + signature, + }; + } + + const redactedThinking = this.getRedactedThinkingData(part); + if (redactedThinking !== undefined) { + return { + type: 'redacted_thinking', + data: redactedThinking, + }; + } + // Text if (part.text) { return { type: 'text', text: part.text }; @@ -245,6 +273,12 @@ export class BetaRunner extends BaseRunner { if (request.tools) { body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); } + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ); + if (thinkingConfig) { + body.thinking = thinkingConfig as BetaMessageCreateParams['thinking']; + } if (request.output?.format && request.output.format !== 'text') { throw new Error( @@ -307,6 +341,12 @@ export class BetaRunner extends BaseRunner { if (request.tools) { body.tools = request.tools.map((tool) => this.toAnthropicTool(tool)); } + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ); + if (thinkingConfig) { + body.thinking = thinkingConfig as BetaMessageCreateParams['thinking']; + } if (request.output?.format && request.output.format !== 'text') { throw new Error( @@ -355,7 +395,7 @@ export class BetaRunner extends BaseRunner { return { text: event.delta.text }; } if (event.delta.type === 'thinking_delta') { - return { text: event.delta.thinking }; + return { reasoning: event.delta.thinking }; } // server/client tool input_json_delta not supported yet return undefined; @@ -407,7 +447,10 @@ export class BetaRunner extends BaseRunner { return { text: contentBlock.text }; case 'thinking': - return { text: contentBlock.thinking }; + return this.createThinkingPart( + contentBlock.thinking, + contentBlock.signature + ); case 'redacted_thinking': return { custom: { redactedThinking: contentBlock.data } }; diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 29cf9836f7..3a30a3cdca 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -20,11 +20,14 @@ import type { DocumentBlockParam, ImageBlockParam, Message, + MessageCreateParams, MessageCreateParamsNonStreaming, MessageCreateParamsStreaming, MessageParam, MessageStreamEvent, + RedactedThinkingBlockParam, TextBlockParam, + ThinkingBlockParam, Tool, ToolResultBlockParam, ToolUseBlockParam, @@ -54,7 +57,9 @@ interface RunnerTypes extends BaseRunnerTypes { | ImageBlockParam | DocumentBlockParam | ToolUseBlockParam - | ToolResultBlockParam; + | ToolResultBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam; } export class Runner extends BaseRunner { @@ -69,7 +74,31 @@ export class Runner extends BaseRunner { | ImageBlockParam | DocumentBlockParam | ToolUseBlockParam - | ToolResultBlockParam { + | ToolResultBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam { + if (part.reasoning) { + 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.' + ); + } + return { + type: 'thinking', + thinking: part.reasoning, + signature, + }; + } + + const redactedThinking = this.getRedactedThinkingData(part); + if (redactedThinking !== undefined) { + return { + type: 'redacted_thinking', + data: redactedThinking, + }; + } + if (part.text) { return { type: 'text', @@ -199,6 +228,12 @@ export class Runner extends BaseRunner { if (request.config?.tool_choice !== undefined) { body.tool_choice = request.config.tool_choice; } + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ); + if (thinkingConfig) { + body.thinking = thinkingConfig as MessageCreateParams['thinking']; + } if (request.output?.format && request.output.format !== 'text') { throw new Error( @@ -264,6 +299,13 @@ export class Runner extends BaseRunner { if (request.config?.tool_choice !== undefined) { body.tool_choice = request.config.tool_choice; } + const thinkingConfig = this.toAnthropicThinkingConfig( + request.config?.thinking + ); + if (thinkingConfig) { + body.thinking = + thinkingConfig as MessageCreateParamsStreaming['thinking']; + } if (request.output?.format && request.output.format !== 'text') { throw new Error( @@ -313,7 +355,7 @@ export class Runner extends BaseRunner { } if (delta.type === 'thinking_delta') { - return { text: delta.thinking }; + return { reasoning: delta.thinking }; } // signature_delta - ignore @@ -348,7 +390,7 @@ export class Runner extends BaseRunner { return { text: block.text }; case 'thinking': - return { text: block.thinking }; + return this.createThinkingPart(block.thinking, block.signature); case 'redacted_thinking': return { custom: { redactedThinking: block.data } }; @@ -410,7 +452,10 @@ export class Runner extends BaseRunner { return { text: contentBlock.text }; case 'thinking': - return { text: contentBlock.thinking }; + return this.createThinkingPart( + contentBlock.thinking, + contentBlock.signature + ); case 'redacted_thinking': return { custom: { redactedThinking: contentBlock.data } }; diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index 898aa7a348..a87139a902 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -64,7 +64,7 @@ export interface ClaudeModelParams extends ClaudeHelperParamsBase {} */ export interface ClaudeRunnerParams extends ClaudeHelperParamsBase {} -export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ +export const AnthropicBaseConfigSchema = GenerationCommonConfigSchema.extend({ tool_choice: z .union([ z.object({ @@ -88,6 +88,36 @@ export const AnthropicConfigSchema = GenerationCommonConfigSchema.extend({ apiVersion: z.enum(['stable', 'beta']).optional(), }); +export type AnthropicBaseConfigSchemaType = typeof AnthropicBaseConfigSchema; + +export const ThinkingConfigSchema = z + .object({ + enabled: z.boolean().optional(), + budgetTokens: z.number().int().min(1_024).optional(), + }) + .superRefine((value, ctx) => { + if (value.enabled && value.budgetTokens === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['budgetTokens'], + message: 'budgetTokens is required when thinking is enabled', + }); + } + }); + +export const AnthropicThinkingConfigSchema = AnthropicBaseConfigSchema.extend({ + thinking: ThinkingConfigSchema.optional(), +}); + +export const AnthropicConfigSchema = AnthropicThinkingConfigSchema; + +export type ThinkingConfig = z.infer; +export type AnthropicBaseConfig = z.infer; +export type AnthropicThinkingConfig = z.infer< + typeof AnthropicThinkingConfigSchema +>; +export type ClaudeConfig = AnthropicThinkingConfig | AnthropicBaseConfig; + /** * Media object representation with URL and optional content type. */ @@ -125,7 +155,7 @@ export const MEDIA_TYPES = { * 3. otherwise stable */ export function resolveBetaEnabled( - cfg: z.infer | undefined, + cfg: AnthropicThinkingConfig | AnthropicBaseConfig | undefined, pluginDefaultApiVersion?: 'stable' | 'beta' ): boolean { if (cfg?.apiVersion !== undefined) { diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 273af968ef..6521906c1a 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -230,7 +230,7 @@ describe('BetaRunner', () => { index: 0, delta: { type: 'thinking_delta', thinking: 'hmm' }, } as any); - assert.deepStrictEqual(deltaPart, { text: 'hmm' }); + assert.deepStrictEqual(deltaPart, { reasoning: 'hmm' }); const ignored = exposed.toGenkitPart({ type: 'message_stop' } as any); assert.strictEqual(ignored, undefined); @@ -377,6 +377,7 @@ describe('BetaRunner', () => { stopSequences: ['DONE'], metadata: { user_id: 'beta-user' }, tool_choice: { type: 'tool', name: 'get_weather' }, + thinking: { enabled: true, budgetTokens: 2048 }, }, tools: [ { @@ -406,6 +407,10 @@ describe('BetaRunner', () => { name: 'get_weather', }); assert.strictEqual(body.tools?.length, 1); + assert.deepStrictEqual(body.thinking, { + type: 'enabled', + budget_tokens: 2048, + }); const streamingBody = runner.toAnthropicStreamingRequestBody( 'claude-3-5-haiku', @@ -414,6 +419,22 @@ describe('BetaRunner', () => { ); assert.strictEqual(streamingBody.stream, true); assert.ok(Array.isArray(streamingBody.system)); + assert.deepStrictEqual(streamingBody.thinking, { + type: 'enabled', + budget_tokens: 2048, + }); + + const disabledBody = runner.toAnthropicRequestBody( + 'claude-3-5-haiku', + { + messages: [], + config: { + thinking: { enabled: false }, + }, + } satisfies any, + false + ); + assert.deepStrictEqual(disabledBody.thinking, { type: 'disabled' }); }); it('should concatenate multiple text parts in system message', () => { @@ -602,8 +623,12 @@ describe('BetaRunner', () => { const thinkingPart = (runner as any).fromBetaContentBlock({ type: 'thinking', thinking: 'pondering', + signature: 'sig_456', + }); + assert.deepStrictEqual(thinkingPart, { + reasoning: 'pondering', + custom: { anthropicThinking: { signature: 'sig_456' } }, }); - assert.deepStrictEqual(thinkingPart, { text: 'pondering' }); const redactedPart = (runner as any).fromBetaContentBlock({ type: 'redacted_thinking', diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index 97554c1c92..09e66b1c4c 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -23,6 +23,7 @@ import { createMockAnthropicClient, createMockAnthropicMessage, mockContentBlockStart, + mockMessageWithContent, mockMessageWithToolUse, mockTextChunk, } from './mocks/anthropic-client.js'; @@ -183,6 +184,57 @@ describe('Anthropic Integration', () => { assert.ok(result.text, 'Should generate response for image input'); }); + it('should forward thinking config and surface reasoning in responses', async () => { + const thinkingContent = [ + { + type: 'thinking' as const, + thinking: 'Let me analyze the problem carefully.', + signature: 'sig_reasoning_123', + }, + { + type: 'text' as const, + text: 'The answer is 42.', + citations: null, + }, + ]; + const mockClient = createMockAnthropicClient({ + messageResponse: mockMessageWithContent(thinkingContent), + }); + + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + const thinkingConfig = { enabled: true, budgetTokens: 2048 }; + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'What is the meaning of life?', + config: { thinking: thinkingConfig }, + }); + + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + const requestBody = createStub.mock.calls[0].arguments[0]; + assert.deepStrictEqual(requestBody.thinking, { + type: 'enabled', + budget_tokens: 2048, + }); + + assert.strictEqual( + result.reasoning, + 'Let me analyze the problem carefully.' + ); + const assistantMessage = result.messages[result.messages.length - 1]; + const reasoningPart = assistantMessage.content.find( + (part) => part.reasoning + ); + assert.ok(reasoningPart, 'Expected reasoning part in assistant message'); + assert.strictEqual( + reasoningPart?.custom?.anthropicThinking?.signature, + 'sig_reasoning_123' + ); + }); + it('should propagate API errors correctly', async () => { const apiError = new Error('API Error: 401 Unauthorized'); const mockClient = createMockAnthropicClient({ @@ -293,4 +345,83 @@ describe('Anthropic Integration', () => { 'Stable API should not be used' ); }); + + it('should stream thinking deltas as reasoning chunks', async () => { + const thinkingConfig = { enabled: true, budgetTokens: 3072 }; + const streamChunks = [ + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'thinking', + thinking: '', + signature: 'sig_stream_123', + }, + } as any, + { + type: 'content_block_delta', + index: 0, + delta: { + type: 'thinking_delta', + thinking: 'Analyzing intermediate steps.', + }, + } as any, + { + type: 'content_block_start', + index: 1, + content_block: { + type: 'text', + text: '', + }, + } as any, + mockTextChunk('Final streamed response.'), + ]; + const finalMessage = mockMessageWithContent([ + { + type: 'thinking', + thinking: 'Analyzing intermediate steps.', + signature: 'sig_stream_123', + }, + { + type: 'text', + text: 'Final streamed response.', + citations: null, + }, + ]); + const mockClient = createMockAnthropicClient({ + streamChunks, + messageResponse: finalMessage, + }); + + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + const chunks: any[] = []; + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + prompt: 'Explain how you reason.', + streamingCallback: (chunk) => chunks.push(chunk), + config: { thinking: thinkingConfig }, + }); + + const streamStub = mockClient.messages.stream as any; + assert.strictEqual(streamStub.mock.calls.length, 1); + const streamRequest = streamStub.mock.calls[0].arguments[0]; + assert.deepStrictEqual(streamRequest.thinking, { + type: 'enabled', + budget_tokens: 3072, + }); + + const hasReasoningChunk = chunks.some((chunk) => + (chunk.content || []).some( + (part: any) => part.reasoning === 'Analyzing intermediate steps.' + ) + ); + assert.ok( + hasReasoningChunk, + 'Expected reasoning chunk in streaming callback' + ); + assert.strictEqual(result.reasoning, 'Analyzing intermediate steps.'); + }); }); diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index a81dbea52d..eae9280ec9 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -34,7 +34,7 @@ import { describe, it, mock } from 'node:test'; import { claudeModel, claudeRunner } from '../src/models.js'; import { Runner } from '../src/runner/stable.js'; -import type { AnthropicConfigSchema } from '../src/types.js'; +import { AnthropicConfigSchema } from '../src/types.js'; import { createMockAnthropicClient, mockContentBlockStart, @@ -682,7 +682,10 @@ describe('fromAnthropicContentBlockChunk', () => { signature: 'sig_123', }, }, - expectedOutput: { text: 'Let me reason through this.' }, + expectedOutput: { + reasoning: 'Let me reason through this.', + custom: { anthropicThinking: { signature: 'sig_123' } }, + }, }, { should: @@ -719,7 +722,7 @@ describe('fromAnthropicContentBlockChunk', () => { thinking: 'Step by step...', }, }, - expectedOutput: { text: 'Step by step...' }, + expectedOutput: { reasoning: 'Step by step...' }, }, { should: 'should return tool use requests', @@ -1271,10 +1274,13 @@ describe('claudeRunner', () => { }, }); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; await runner( { messages: [] }, @@ -1319,10 +1325,13 @@ describe('claudeRunner', () => { }); const streamingCallback = mock.fn(); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; await runner( { messages: [] }, @@ -1357,10 +1366,13 @@ describe('claudeRunner', () => { }, }); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; await runner( { @@ -1390,11 +1402,14 @@ describe('claudeRunner', () => { }, }); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - defaultApiVersion: 'beta', - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'beta', + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; await runner( { @@ -1424,11 +1439,14 @@ describe('claudeRunner', () => { }); // defaultApiVersion is 'stable', but request overrides to 'beta' - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - defaultApiVersion: 'stable', - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'stable', + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; await runner( { @@ -1459,11 +1477,14 @@ describe('claudeRunner', () => { }); // defaultApiVersion is 'beta', but request overrides to 'stable' - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - defaultApiVersion: 'beta', - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'beta', + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; await runner( { @@ -1484,11 +1505,14 @@ describe('claudeRunner', () => { describe('claudeRunner param object', () => { it('should run requests when constructed with params object', async () => { const mockClient = createMockAnthropicClient(); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - cacheSystemPrompt: true, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + cacheSystemPrompt: true, + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; await runner( @@ -1506,11 +1530,14 @@ describe('claudeRunner param object', () => { it('should route to beta runner when defaultApiVersion is beta', async () => { const mockClient = createMockAnthropicClient(); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - defaultApiVersion: 'beta', - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + defaultApiVersion: 'beta', + }, + AnthropicConfigSchema + ); await runner( { messages: [] }, { @@ -1526,10 +1553,13 @@ describe('claudeRunner param object', () => { it('should throw when client is omitted from params object', () => { assert.throws(() => { - claudeRunner({ - name: 'claude-3-5-haiku', - client: undefined as unknown as Anthropic, - }); + claudeRunner( + { + name: 'claude-3-5-haiku', + client: undefined as unknown as Anthropic, + }, + AnthropicConfigSchema + ); }, /Anthropic client is required to create a runner/); }); }); @@ -1631,10 +1661,13 @@ describe('claudeModel', () => { chunks.push(chunk); }); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; const result = await runner( @@ -1696,10 +1729,13 @@ describe('claudeModel', () => { chunks.push(chunk); }); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; const result = await runner( @@ -1754,10 +1790,13 @@ describe('claudeModel', () => { }, }); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + }, + AnthropicConfigSchema + ); const abortSignal = new AbortController().signal; const chunks: any[] = []; const sendChunk = (chunk: any) => { @@ -1807,10 +1846,13 @@ describe('claudeModel', () => { }, }); - const runner = claudeRunner({ - name: 'claude-3-5-haiku', - client: mockClient, - }); + const runner = claudeRunner( + { + name: 'claude-3-5-haiku', + client: mockClient, + }, + AnthropicConfigSchema + ); const abortController = new AbortController(); const chunks: any[] = []; const sendChunk = (chunk: any) => { @@ -1979,6 +2021,7 @@ describe('Runner request bodies and error branches', () => { stopSequences: ['END'], metadata: { user_id: 'user-xyz' }, tool_choice: { type: 'auto' }, + thinking: { enabled: true, budgetTokens: 2048 }, }, tools: [ { @@ -2002,6 +2045,10 @@ describe('Runner request bodies and error branches', () => { assert.deepStrictEqual(body.metadata, { user_id: 'user-xyz' }); assert.deepStrictEqual(body.tool_choice, { type: 'auto' }); assert.strictEqual(body.tools?.length, 1); + assert.deepStrictEqual(body.thinking, { + type: 'enabled', + budget_tokens: 2048, + }); }); it('should include optional config fields in streaming request body', () => { @@ -2033,6 +2080,7 @@ describe('Runner request bodies and error branches', () => { stopSequences: ['STOP'], metadata: { user_id: 'user-abc' }, tool_choice: { type: 'any' }, + thinking: { enabled: true, budgetTokens: 1536 }, }, tools: [ { @@ -2055,6 +2103,31 @@ describe('Runner request bodies and error branches', () => { assert.deepStrictEqual(body.metadata, { user_id: 'user-abc' }); assert.deepStrictEqual(body.tool_choice, { type: 'any' }); assert.strictEqual(body.tools?.length, 1); + assert.deepStrictEqual(body.thinking, { + type: 'enabled', + budget_tokens: 1536, + }); + }); + + it('should disable thinking when explicitly turned off', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }) as Runner & RunnerProtectedMethods; + + const body = runner['toAnthropicRequestBody']( + 'claude-3-5-haiku', + { + messages: [], + config: { + thinking: { enabled: false }, + }, + } as unknown as GenerateRequest, + false + ); + + assert.deepStrictEqual(body.thinking, { type: 'disabled' }); }); it('should throw descriptive errors for missing tool refs', () => { From 2990cdd50f2d411cc06968ac3a319c9463f4f8a2 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 17 Nov 2025 14:42:56 +0000 Subject: [PATCH 42/51] chore(js/plugins/anthropic): update license headers --- js/plugins/anthropic/NOTICE | 4 +- js/plugins/anthropic/src/index.ts | 4 +- js/plugins/anthropic/src/list.ts | 4 +- js/plugins/anthropic/src/models.ts | 4 +- js/plugins/anthropic/src/runner/base.ts | 2 +- js/plugins/anthropic/src/runner/beta.ts | 2 +- js/plugins/anthropic/src/runner/index.ts | 2 +- js/plugins/anthropic/src/runner/stable.ts | 2 +- js/plugins/anthropic/src/runner/types.ts | 2 +- js/plugins/anthropic/src/types.ts | 4 +- js/pnpm-lock.yaml | 90 ++++++++++------------- 11 files changed, 53 insertions(+), 67 deletions(-) diff --git a/js/plugins/anthropic/NOTICE b/js/plugins/anthropic/NOTICE index 9222b48dfe..dc335bb090 100644 --- a/js/plugins/anthropic/NOTICE +++ b/js/plugins/anthropic/NOTICE @@ -1,8 +1,8 @@ This project includes code derived from the Firebase Genkit Anthropic community plugin (https://github.com/BloomLabsInc/genkit-plugins/tree/main/plugins/anthropic). -Original work Copyright 2024 Bloom Labs Inc. -Modifications Copyright 2025 Google LLC. +Copyright 2024 Bloom Labs Inc. +Copyright 2025 Google LLC. Licensed under the Apache License, Version 2.0. See the LICENSE file distributed with this project for the full license text. diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index f23d41ed60..05a882122a 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -1,6 +1,6 @@ /** - * Original work Copyright 2024 Bloom Labs Inc - * Modifications Copyright 2025 Google LLC + * Copyright 2024 Bloom Labs Inc + * 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. diff --git a/js/plugins/anthropic/src/list.ts b/js/plugins/anthropic/src/list.ts index e2a1cdfcdc..bafb131844 100644 --- a/js/plugins/anthropic/src/list.ts +++ b/js/plugins/anthropic/src/list.ts @@ -1,6 +1,6 @@ /** - * Original work Copyright 2024 Bloom Labs Inc - * Modifications Copyright 2025 Google LLC + * Copyright 2024 Bloom Labs Inc + * 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. diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index 4b848cebe2..fcf0daf7d8 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -1,6 +1,6 @@ /** - * Original work Copyright 2024 Bloom Labs Inc - * Modifications Copyright 2025 Google LLC + * Copyright 2024 Bloom Labs Inc + * 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. diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index 8ce7ea1353..cb42b293dd 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -1,5 +1,5 @@ /** - * Modifications Copyright 2025 Google LLC + * 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. diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 0e48381f06..85333d246d 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -1,5 +1,5 @@ /** - * Modifications Copyright 2025 Google LLC + * 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. diff --git a/js/plugins/anthropic/src/runner/index.ts b/js/plugins/anthropic/src/runner/index.ts index e6b6093fcb..ce7e3c6fdd 100644 --- a/js/plugins/anthropic/src/runner/index.ts +++ b/js/plugins/anthropic/src/runner/index.ts @@ -1,5 +1,5 @@ /** - * Modifications Copyright 2025 Google LLC + * 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. diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 3a30a3cdca..7259a23997 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -1,5 +1,5 @@ /** - * Modifications Copyright 2025 Google LLC + * 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. diff --git a/js/plugins/anthropic/src/runner/types.ts b/js/plugins/anthropic/src/runner/types.ts index c39fa75f71..5fd04c6911 100644 --- a/js/plugins/anthropic/src/runner/types.ts +++ b/js/plugins/anthropic/src/runner/types.ts @@ -1,5 +1,5 @@ /** - * Modifications Copyright 2025 Google LLC + * 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. diff --git a/js/plugins/anthropic/src/types.ts b/js/plugins/anthropic/src/types.ts index a87139a902..9796d71c36 100644 --- a/js/plugins/anthropic/src/types.ts +++ b/js/plugins/anthropic/src/types.ts @@ -1,6 +1,6 @@ /** - * Original work Copyright 2024 Bloom Labs Inc - * Modifications Copyright 2025 Google LLC + * Copyright 2024 Bloom Labs Inc + * 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. diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index 504e8571b5..fa38c1f220 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -181,7 +181,7 @@ importers: optionalDependencies: '@genkit-ai/firebase': specifier: ^1.16.1 - version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) + version: 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) doc-snippets: dependencies: @@ -979,21 +979,7 @@ importers: version: 3.3.2 openai: specifier: ^4.52.7 -<<<<<<< HEAD version: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67) -======= - version: 4.104.0(encoding@0.1.13)(zod@3.25.67) -<<<<<<< HEAD - optionalDependencies: - '@google-cloud/bigquery': - specifier: ^7.8.0 - version: 7.9.4(encoding@0.1.13) - firebase-admin: - specifier: '>=12.2' - version: 13.4.0(encoding@0.1.13) ->>>>>>> 5cbe620e1 (refactor(anthropic): a lot of refactoring and add beta api) -======= ->>>>>>> ead4341c2 (refactor(anthropic): add basic testapp and remove nested ternary) devDependencies: '@types/node': specifier: ^20.11.16 @@ -1065,7 +1051,7 @@ importers: version: link:../../plugins/google-genai '@google/genai': specifier: ^1.29.0 - version: 1.29.0 + version: 1.29.1 express: specifier: ^4.20.0 version: 4.21.2 @@ -1093,7 +1079,7 @@ importers: version: link:../../plugins/compat-oai '@genkit-ai/express': specifier: ^1.1.0 - version: 1.12.0(@genkit-ai/core@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit) + version: 1.12.0(@genkit-ai/core@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit) genkit: specifier: workspace:* version: link:../../genkit @@ -1660,7 +1646,7 @@ importers: version: link:../../plugins/ollama genkitx-openai: specifier: ^0.10.1 - version: 0.10.1(@genkit-ai/ai@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3) + version: 0.10.1(@genkit-ai/ai@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3) devDependencies: rimraf: specifier: ^6.0.1 @@ -2702,11 +2688,11 @@ packages: '@firebase/webchannel-wrapper@1.0.3': resolution: {integrity: sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==} - '@genkit-ai/ai@1.22.0': - resolution: {integrity: sha512-TDKO+zWyM5YI8zE4a0IlqlpgHuLB4B4islzgWDvzdQlbjtyJp0ayODAMFhS2ruQ6+a/UdXDySRrOX/RcqF4yjA==} + '@genkit-ai/ai@1.23.0': + resolution: {integrity: sha512-9CuZJYZnnmAtqVcOEDU/bSCJYPELu2oveEoOSECVA6St3bmebID9mL0F51AKGi6YyB1Xz+GLny35PTbqxrhAWw==} - '@genkit-ai/core@1.22.0': - resolution: {integrity: sha512-etVlpwJkPoy91xR6H5+S/AWZPJMeovb7N35+B90md1+6xWcodQF7WZ3chKcH31Xamlz+jTIvd3riiZGY9RFumg==} + '@genkit-ai/core@1.23.0': + resolution: {integrity: sha512-JG5QPwM49HZmH69ky2DZUevJLEkeIkAo1EdIbYC0E32cH4VZp0Mn1ooA73oTLZm0/D/LrJ2zc5487Eu3gIhs9Q==} '@genkit-ai/express@1.12.0': resolution: {integrity: sha512-QAxSS07dX5ovSfsUB4s90KaDnv4zg1wnoxCZCa+jBsYUyv9NvCCTsOk25xAQgGxc7xi3+MD+3AsPier5oZILIg==} @@ -2820,8 +2806,8 @@ packages: resolution: {integrity: sha512-HqYqoivNtkq59po8m7KI0n+lWKdz4kabENncYQXZCX/hBWJfXtKAfR/2nUQsP+TwSfHKoA7zDL2RrJYIv/j3VQ==} engines: {node: '>=18.0.0'} - '@google/genai@1.29.0': - resolution: {integrity: sha512-cQP7Ssa06W+MSAyVtL/812FBtZDoDehnFObIpK1xo5Uv4XvqBcVZ8OhXgihOIXWn7xvPQGvLclR8+yt3Ysnd9g==} + '@google/genai@1.29.1': + resolution: {integrity: sha512-Buywpq0A6xf9cOdhiWCi5KUiDBbZkjCH5xbl+xxNQRItoYQgd31p0OKyn5cUnT0YNzC/pAmszqXoOc7kncqfFQ==} engines: {node: '>=20.0.0'} peerDependencies: '@modelcontextprotocol/sdk': ^1.20.1 @@ -5361,8 +5347,8 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} - genkit@1.22.0: - resolution: {integrity: sha512-GoVVO3EnNHrjkMkUPRvgx1MjBHKvOlZAu/ffMIJgLFxrH7rrUbvfHXE6Nk7uh5BNvET7+DApyhbhqz9G8sy+mQ==} + genkit@1.23.0: + resolution: {integrity: sha512-8BOpwt40Yqadc6shvfcKk4fTzrtlIV8ntv9Mqx3Yul9BbM4kRXW+h6nYMaPMKT4ZdErzZgnfxU7e6t1lXFFP4A==} genkitx-openai@0.10.1: resolution: {integrity: sha512-E9/DzyQcBUSTy81xT2pvEmdnn9Q/cKoojEt6lD/EdOeinhqE9oa59d/kuXTokCMekTrj3Rk7LtNBQIDjnyjNOA==} @@ -5455,8 +5441,8 @@ packages: resolution: {integrity: sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==} engines: {node: '>=14'} - google-logging-utils@1.1.2: - resolution: {integrity: sha512-YsFPGVgDFf4IzSwbwIR0iaFJQFmR5Jp7V1WuYSjuRgAm9yWqsMhKE9YPlL+wvFLnc/wMiFV4SQUD9Y/JMpxIxQ==} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} google-p12-pem@4.0.1: @@ -8621,9 +8607,9 @@ snapshots: '@firebase/webchannel-wrapper@1.0.3': {} - '@genkit-ai/ai@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/ai@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: - '@genkit-ai/core': 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/core': 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) '@opentelemetry/api': 1.9.0 '@types/node': 20.19.1 colorette: 2.0.20 @@ -8642,9 +8628,9 @@ snapshots: - supports-color optional: true - '@genkit-ai/ai@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': + '@genkit-ai/ai@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': dependencies: - '@genkit-ai/core': 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/core': 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) '@opentelemetry/api': 1.9.0 '@types/node': 20.19.1 colorette: 2.0.20 @@ -8662,7 +8648,7 @@ snapshots: - genkit - supports-color - '@genkit-ai/core@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/core@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 1.25.1(@opentelemetry/api@1.9.0) @@ -8684,7 +8670,7 @@ snapshots: zod: 3.25.67 zod-to-json-schema: 3.24.5(zod@3.25.67) optionalDependencies: - '@genkit-ai/firebase': 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/firebase': 1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) transitivePeerDependencies: - '@google-cloud/firestore' - encoding @@ -8694,7 +8680,7 @@ snapshots: - supports-color optional: true - '@genkit-ai/core@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': + '@genkit-ai/core@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/context-async-hooks': 1.25.1(@opentelemetry/api@1.9.0) @@ -8725,9 +8711,9 @@ snapshots: - genkit - supports-color - '@genkit-ai/express@1.12.0(@genkit-ai/core@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit)': + '@genkit-ai/express@1.12.0(@genkit-ai/core@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(express@5.1.0)(genkit@genkit)': dependencies: - '@genkit-ai/core': 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/core': 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) body-parser: 1.20.3 cors: 2.8.5 express: 5.1.0 @@ -8735,12 +8721,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@genkit-ai/firebase@1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/firebase@1.16.1(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: - '@genkit-ai/google-cloud': 1.16.1(encoding@0.1.13)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/google-cloud': 1.16.1(encoding@0.1.13)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) '@google-cloud/firestore': 7.11.1(encoding@0.1.13) firebase-admin: 13.4.0(encoding@0.1.13) - genkit: 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1) + genkit: 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1) optionalDependencies: firebase: 11.9.1 transitivePeerDependencies: @@ -8761,7 +8747,7 @@ snapshots: - supports-color optional: true - '@genkit-ai/google-cloud@1.16.1(encoding@0.1.13)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': + '@genkit-ai/google-cloud@1.16.1(encoding@0.1.13)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1))': dependencies: '@google-cloud/logging-winston': 6.0.1(encoding@0.1.13)(winston@3.17.0) '@google-cloud/opentelemetry-cloud-monitoring-exporter': 0.19.0(@opentelemetry/api@1.9.0)(@opentelemetry/core@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.25.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.25.1(@opentelemetry/api@1.9.0))(encoding@0.1.13) @@ -8777,7 +8763,7 @@ snapshots: '@opentelemetry/sdk-metrics': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-node': 0.52.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) - genkit: 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1) + genkit: 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1) google-auth-library: 9.15.1(encoding@0.1.13) node-fetch: 3.3.2 winston: 3.17.0 @@ -9004,7 +8990,7 @@ snapshots: - encoding - supports-color - '@google/genai@1.29.0': + '@google/genai@1.29.1': dependencies: google-auth-library: 10.5.0 ws: 8.18.3 @@ -11835,15 +11821,15 @@ snapshots: gcp-metadata@8.1.2: dependencies: gaxios: 7.1.3 - google-logging-utils: 1.1.2 + google-logging-utils: 1.1.3 json-bigint: 1.0.0 transitivePeerDependencies: - supports-color - genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1): + genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1): dependencies: - '@genkit-ai/ai': 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) - '@genkit-ai/core': 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/ai': 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) + '@genkit-ai/core': 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)) uuid: 10.0.0 transitivePeerDependencies: - '@google-cloud/firestore' @@ -11853,10 +11839,10 @@ snapshots: - supports-color optional: true - genkitx-openai@0.10.1(@genkit-ai/ai@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3): + genkitx-openai@0.10.1(@genkit-ai/ai@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(@genkit-ai/core@1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit))(encoding@0.1.13)(ws@8.18.3): dependencies: - '@genkit-ai/ai': 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) - '@genkit-ai/core': 1.22.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/ai': 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) + '@genkit-ai/core': 1.23.0(@google-cloud/firestore@7.11.1(encoding@0.1.13))(encoding@0.1.13)(firebase-admin@13.4.0(encoding@0.1.13))(firebase@11.9.1)(genkit@genkit) openai: 4.104.0(encoding@0.1.13)(ws@8.18.3)(zod@3.25.67) zod: 3.25.67 transitivePeerDependencies: @@ -11956,7 +11942,7 @@ snapshots: ecdsa-sig-formatter: 1.0.11 gaxios: 7.1.3 gcp-metadata: 8.1.2 - google-logging-utils: 1.1.2 + google-logging-utils: 1.1.3 gtoken: 8.0.0 jws: 4.0.0 transitivePeerDependencies: @@ -12008,7 +11994,7 @@ snapshots: - encoding - supports-color - google-logging-utils@1.1.2: {} + google-logging-utils@1.1.3: {} google-p12-pem@4.0.1: dependencies: From 41587501e5c57d21e9bb755d61df3b6860997705 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 17 Nov 2025 15:04:12 +0000 Subject: [PATCH 43/51] fix(js/plugins/anthropic): handle webp and text/plain --- js/plugins/anthropic/src/runner/base.ts | 45 ++++- .../anthropic/tests/beta_runner_test.ts | 105 +++++++++++ .../anthropic/tests/integration_test.ts | 115 ++++++++++++ .../anthropic/tests/stable_runner_test.ts | 169 ++++++++++++++++++ 4 files changed, 431 insertions(+), 3 deletions(-) diff --git a/js/plugins/anthropic/src/runner/base.ts b/js/plugins/anthropic/src/runner/base.ts index cb42b293dd..e6b7132e28 100644 --- a/js/plugins/anthropic/src/runner/base.ts +++ b/js/plugins/anthropic/src/runner/base.ts @@ -179,11 +179,18 @@ export abstract class BaseRunner { )}.` ); } - const resolvedMediaType = media.contentType ?? contentType; + + const resolvedMediaType = contentType; if (!resolvedMediaType) { throw new Error('Media type is required but was not provided'); } if (!this.isMediaType(resolvedMediaType)) { + // Provide helpful error message for text files + if (resolvedMediaType === 'text/plain') { + throw new Error( + `Unsupported media type: ${resolvedMediaType}. Text files should be sent as text content in the message, not as media. For example, use { text: '...' } instead of { media: { url: '...', contentType: 'text/plain' } }` + ); + } throw new Error(`Unsupported media type: ${resolvedMediaType}`); } return { @@ -197,6 +204,20 @@ export abstract class BaseRunner { throw new Error('Media url is required but was not provided'); } + // For non-data URLs, use the provided contentType or default to a generic type + // Note: Anthropic will validate the actual content when fetching from URL + if (media.contentType) { + if (!this.isMediaType(media.contentType)) { + // Provide helpful error message for text files + if (media.contentType === 'text/plain') { + throw new Error( + `Unsupported media type: ${media.contentType}. Text files should be sent as text content in the message, not as media. For example, use { text: '...' } instead of { media: { url: '...', contentType: 'text/plain' } }` + ); + } + throw new Error(`Unsupported media type: ${media.contentType}`); + } + } + return { kind: 'url', url: media.url, @@ -216,7 +237,16 @@ export abstract class BaseRunner { if (this.isMediaObject(output)) { const { data, contentType } = this.extractDataFromBase64Url(output.url) ?? {}; - if (data && contentType && this.isMediaType(contentType)) { + if (data && contentType) { + if (!this.isMediaType(contentType)) { + // Provide helpful error message for text files + if (contentType === 'text/plain') { + throw new Error( + `Unsupported media type: ${contentType}. Text files should be sent as text content, not as media.` + ); + } + throw new Error(`Unsupported media type: ${contentType}`); + } return { type: 'image', source: { @@ -234,7 +264,16 @@ export abstract class BaseRunner { if (this.isDataUrl(output)) { const { data, contentType } = this.extractDataFromBase64Url(output) ?? {}; - if (data && contentType && this.isMediaType(contentType)) { + if (data && contentType) { + if (!this.isMediaType(contentType)) { + // Provide helpful error message for text files + if (contentType === 'text/plain') { + throw new Error( + `Unsupported media type: ${contentType}. Text files should be sent as text content, not as media.` + ); + } + throw new Error(`Unsupported media type: ${contentType}`); + } return { type: 'image', source: { diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index 6521906c1a..d9e9c93479 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -110,6 +110,111 @@ describe('BetaRunner.toAnthropicMessageContent', () => { assert.strictEqual(result.tool_use_id, 'tool-abc'); assert.deepStrictEqual(result.content, [{ type: 'text', text: 'done' }]); }); + + it('should handle WEBP image data URLs', () => { + const runner = createRunner(); + const part: Part = { + media: { + contentType: 'image/webp', + url: 'data:image/webp;base64,AAA', + }, + }; + + const result = (runner as any).toAnthropicMessageContent(part); + + assert.strictEqual(result.type, 'image'); + assert.strictEqual(result.source.type, 'base64'); + assert.strictEqual(result.source.media_type, 'image/webp'); + assert.strictEqual(result.source.data, 'AAA'); + }); + + it('should prefer data URL content type over media.contentType for WEBP', () => { + const runner = createRunner(); + const part: Part = { + media: { + // Even if contentType says PNG, data URL says WEBP - should use WEBP + contentType: 'image/png', + url: 'data:image/webp;base64,AAA', + }, + }; + + const result = (runner as any).toAnthropicMessageContent(part); + + assert.strictEqual(result.type, 'image'); + assert.strictEqual(result.source.type, 'base64'); + // Key fix: should use data URL type (webp), not contentType (png) + assert.strictEqual(result.source.media_type, 'image/webp'); + assert.strictEqual(result.source.data, 'AAA'); + }); + + it('should throw helpful error for text/plain in toAnthropicMessageContent', () => { + const runner = createRunner(); + const part: Part = { + media: { + contentType: 'text/plain', + url: 'data:text/plain;base64,AAA', + }, + }; + + assert.throws( + () => { + (runner as any).toAnthropicMessageContent(part); + }, + (error: Error) => { + return ( + error.message.includes('Text files should be sent as text content') && + error.message.includes('text:') + ); + } + ); + }); + + it('should throw helpful error for text/plain with remote URL', () => { + const runner = createRunner(); + const part: Part = { + media: { + contentType: 'text/plain', + url: 'https://example.com/file.txt', + }, + }; + + assert.throws( + () => { + (runner as any).toAnthropicMessageContent(part); + }, + (error: Error) => { + return ( + error.message.includes('Text files should be sent as text content') && + error.message.includes('text:') + ); + } + ); + }); + + it('should throw helpful error for text/plain in tool response', () => { + const runner = createRunner(); + const part: Part = { + toolResponse: { + ref: 'call_123', + name: 'get_file', + output: { + url: 'data:text/plain;base64,AAA', + contentType: 'text/plain', + }, + }, + }; + + assert.throws( + () => { + (runner as any).toAnthropicToolResponseContent(part); + }, + (error: Error) => { + return error.message.includes( + 'Text files should be sent as text content' + ); + } + ); + }); }); /** * Copyright 2025 Google LLC diff --git a/js/plugins/anthropic/tests/integration_test.ts b/js/plugins/anthropic/tests/integration_test.ts index 09e66b1c4c..209a455870 100644 --- a/js/plugins/anthropic/tests/integration_test.ts +++ b/js/plugins/anthropic/tests/integration_test.ts @@ -184,6 +184,121 @@ describe('Anthropic Integration', () => { assert.ok(result.text, 'Should generate response for image input'); }); + it('should handle WEBP image inputs', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + messages: [ + { + role: 'user', + content: [ + { text: 'Describe this image:' }, + { + media: { + url: 'data:image/webp;base64,AAA', + contentType: 'image/webp', + }, + }, + ], + }, + ], + }); + + assert.ok(result.text, 'Should generate response for WEBP image input'); + // Verify the request was made with correct media_type + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + const requestBody = createStub.mock.calls[0].arguments[0]; + const imageContent = requestBody.messages[0].content.find( + (c: any) => c.type === 'image' + ); + assert.ok(imageContent, 'Should have image content in request'); + assert.strictEqual( + imageContent.source.media_type, + 'image/webp', + 'Should use WEBP media type from data URL' + ); + }); + + it('should handle WEBP image with mismatched contentType (prefers data URL)', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + const result = await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + messages: [ + { + role: 'user', + content: [ + { + media: { + // Data URL says WEBP, but contentType says PNG - should use WEBP + url: 'data:image/webp;base64,AAA', + contentType: 'image/png', + }, + }, + ], + }, + ], + }); + + assert.ok(result.text, 'Should generate response for WEBP image input'); + // Verify the request was made with WEBP (from data URL), not PNG (from contentType) + const createStub = mockClient.messages.create as any; + assert.strictEqual(createStub.mock.calls.length, 1); + const requestBody = createStub.mock.calls[0].arguments[0]; + const imageContent = requestBody.messages[0].content.find( + (c: any) => c.type === 'image' + ); + assert.ok(imageContent, 'Should have image content in request'); + assert.strictEqual( + imageContent.source.media_type, + 'image/webp', + 'Should prefer data URL content type (webp) over contentType (png)' + ); + }); + + it('should throw helpful error for text/plain media', async () => { + const mockClient = createMockAnthropicClient(); + const ai = genkit({ + plugins: [anthropic({ [__testClient]: mockClient } as PluginOptions)], + }); + + await assert.rejects( + async () => { + await ai.generate({ + model: 'anthropic/claude-3-5-haiku', + messages: [ + { + role: 'user', + content: [ + { + media: { + url: 'data:text/plain;base64,AAA', + contentType: 'text/plain', + }, + }, + ], + }, + ], + }); + }, + (error: Error) => { + return ( + error.message.includes('Text files should be sent as text content') && + error.message.includes('text:') + ); + }, + 'Should throw helpful error for text/plain media' + ); + }); + it('should forward thinking config and surface reasoning in responses', async () => { const thinkingContent = [ { diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index eae9280ec9..e1ed20ff7e 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -1989,6 +1989,175 @@ describe('BaseRunner helper utilities', () => { } assert.strictEqual(source.url, 'https://example.com/image.png'); }); + + it('should parse WEBP image data URLs with matching contentType', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); + + const source = runner['toImageSource']({ + url: 'data:image/webp;base64,AAA', + contentType: 'image/webp', + }); + + assert.strictEqual(source.kind, 'base64'); + if (source.kind !== 'base64') { + throw new Error('Expected base64 image source'); + } + assert.strictEqual(source.mediaType, 'image/webp'); + assert.strictEqual(source.data, 'AAA'); + }); + + it('should prefer data URL content type over media.contentType for WEBP', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); + + // Even if contentType says PNG, data URL says WEBP - should use WEBP + const source = runner['toImageSource']({ + url: 'data:image/webp;base64,AAA', + contentType: 'image/png', + }); + + assert.strictEqual(source.kind, 'base64'); + if (source.kind !== 'base64') { + throw new Error('Expected base64 image source'); + } + // Key fix: should use data URL type (webp), not contentType (png) + assert.strictEqual(source.mediaType, 'image/webp'); + assert.strictEqual(source.data, 'AAA'); + }); + + it('should handle WEBP via toAnthropicMessageContent', () => { + const result = testRunner.toAnthropicMessageContent({ + media: { + url: 'data:image/webp;base64,AAA', + contentType: 'image/webp', + }, + }); + + assert.strictEqual(result.type, 'image'); + assert.strictEqual(result.source.type, 'base64'); + assert.strictEqual(result.source.media_type, 'image/webp'); + assert.strictEqual(result.source.data, 'AAA'); + }); + + it('should handle WEBP in tool response content', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); + + const result = runner['toAnthropicToolResponseContent']({ + toolResponse: { + ref: 'call_123', + name: 'get_image', + output: { + url: 'data:image/webp;base64,AAA', + contentType: 'image/webp', + }, + }, + } as any); + + assert.strictEqual(result.type, 'image'); + assert.strictEqual(result.source.type, 'base64'); + assert.strictEqual(result.source.media_type, 'image/webp'); + assert.strictEqual(result.source.data, 'AAA'); + }); + + it('should throw helpful error for text/plain in toImageSource', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); + + assert.throws( + () => + runner['toImageSource']({ + url: 'data:text/plain;base64,AAA', + contentType: 'text/plain', + }), + (error: Error) => { + return ( + error.message.includes('Text files should be sent as text content') && + error.message.includes('text:') + ); + } + ); + }); + + it('should throw helpful error for text/plain in toAnthropicMessageContent', () => { + assert.throws( + () => + testRunner.toAnthropicMessageContent({ + media: { + url: 'data:text/plain;base64,AAA', + contentType: 'text/plain', + }, + }), + (error: Error) => { + return ( + error.message.includes('Text files should be sent as text content') && + error.message.includes('text:') + ); + } + ); + }); + + it('should throw helpful error for text/plain in tool response', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); + + assert.throws( + () => + runner['toAnthropicToolResponseContent']({ + toolResponse: { + ref: 'call_123', + name: 'get_file', + output: { + url: 'data:text/plain;base64,AAA', + contentType: 'text/plain', + }, + }, + } as any), + (error: Error) => { + return error.message.includes( + 'Text files should be sent as text content' + ); + } + ); + }); + + it('should throw helpful error for text/plain with remote URL', () => { + const mockClient = createMockAnthropicClient(); + const runner = new Runner({ + name: 'claude-3-5-haiku', + client: mockClient, + }); + + assert.throws( + () => + runner['toImageSource']({ + url: 'https://example.com/file.txt', + contentType: 'text/plain', + }), + (error: Error) => { + return ( + error.message.includes('Text files should be sent as text content') && + error.message.includes('text:') + ); + } + ); + }); }); describe('Runner request bodies and error branches', () => { From edd7134b943068fca434f860bd382b4f344976bf Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 17 Nov 2025 15:35:33 +0000 Subject: [PATCH 44/51] test(js/testapps/anthropic): add extra flows for manual tests --- js/pnpm-lock.yaml | 2 +- js/testapps/anthropic-basic/README.md | 23 ---- js/testapps/anthropic/README.md | 66 ++++++++++ .../package.json | 21 +-- .../beta.ts => anthropic/src/beta/basic.ts} | 0 .../src/stable/attention-first-page.pdf | Bin 0 -> 92454 bytes .../src/stable/basic.ts} | 0 js/testapps/anthropic/src/stable/pdf.ts | 122 ++++++++++++++++++ .../anthropic/src/stable/text-plain.ts | 83 ++++++++++++ js/testapps/anthropic/src/stable/webp.ts | 95 ++++++++++++++ .../tsconfig.json | 0 11 files changed, 379 insertions(+), 33 deletions(-) delete mode 100644 js/testapps/anthropic-basic/README.md create mode 100644 js/testapps/anthropic/README.md rename js/testapps/{anthropic-basic => anthropic}/package.json (51%) rename js/testapps/{anthropic-basic/src/beta.ts => anthropic/src/beta/basic.ts} (100%) create mode 100644 js/testapps/anthropic/src/stable/attention-first-page.pdf rename js/testapps/{anthropic-basic/src/stable.ts => anthropic/src/stable/basic.ts} (100%) create mode 100644 js/testapps/anthropic/src/stable/pdf.ts create mode 100644 js/testapps/anthropic/src/stable/text-plain.ts create mode 100644 js/testapps/anthropic/src/stable/webp.ts rename js/testapps/{anthropic-basic => anthropic}/tsconfig.json (100%) diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index fa38c1f220..328d92253e 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -1019,7 +1019,7 @@ importers: specifier: '>=12.2' version: 13.4.0(encoding@0.1.13) - testapps/anthropic-basic: + testapps/anthropic: dependencies: '@genkit-ai/anthropic': specifier: workspace:* diff --git a/js/testapps/anthropic-basic/README.md b/js/testapps/anthropic-basic/README.md deleted file mode 100644 index 8863e81321..0000000000 --- a/js/testapps/anthropic-basic/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Anthropic Plugin Sample - -This test app demonstrates minimal usage of the Genkit Anthropic plugin against both the stable and beta runners. - -## Setup - -1. From the repo root run `pnpm install` followed by `pnpm run setup` to link workspace dependencies. -2. In this directory, optionally run `pnpm install` if you want a local `node_modules/`. -3. Export an Anthropic API key (or add it to a `.env` file) before running any samples: - - ```bash - export ANTHROPIC_API_KEY=your-key - ``` - -## Available scripts - -- `pnpm run build` – Compile the TypeScript sources into `lib/`. -- `pnpm run start:stable` – Run the compiled stable sample. -- `pnpm run start:beta` – Run the compiled beta sample. -- `pnpm run dev:stable` – Start the Genkit Dev UI over `src/stable.ts` with live reload. -- `pnpm run dev:beta` – Start the Genkit Dev UI over `src/beta.ts` with live reload. - -Each source file defines a couple of flows that can be invoked from the Dev UI or the Genkit CLI (for example, `genkit flow:run anthropic-stable-hello`). diff --git a/js/testapps/anthropic/README.md b/js/testapps/anthropic/README.md new file mode 100644 index 0000000000..43a00f468c --- /dev/null +++ b/js/testapps/anthropic/README.md @@ -0,0 +1,66 @@ +# Anthropic Plugin Sample + +This test app demonstrates usage of the Genkit Anthropic plugin against both the stable and beta runners, organized by feature. + +## Directory Structure + +``` +src/ + stable/ + basic.ts - Basic stable API examples (hello, streaming) + text-plain.ts - Text/plain error handling demonstration + webp.ts - WEBP image handling demonstration + pdf.ts - PDF document processing examples + attention-first-page.pdf - Sample PDF file for testing + beta/ + basic.ts - Basic beta API examples +``` + +## Setup + +1. From the repo root run `pnpm install` followed by `pnpm run setup` to link workspace dependencies. +2. In this directory, optionally run `pnpm install` if you want a local `node_modules/`. +3. Export an Anthropic API key (or add it to a `.env` file) before running any samples: + + ```bash + export ANTHROPIC_API_KEY=your-key + ``` + +## Available scripts + +### Basic Examples +- `pnpm run build` – Compile the TypeScript sources into `lib/`. +- `pnpm run start:stable` – Run the compiled stable basic sample. +- `pnpm run start:beta` – Run the compiled beta basic sample. +- `pnpm run dev:stable` – Start the Genkit Dev UI over `src/stable/basic.ts` with live reload. +- `pnpm run dev:beta` – Start the Genkit Dev UI over `src/beta/basic.ts` with live reload. + +### Feature-Specific Examples +- `pnpm run dev:stable:text-plain` – Start Dev UI for text/plain error handling demo. +- `pnpm run dev:stable:webp` – Start Dev UI for WEBP image handling demo. +- `pnpm run dev:stable:pdf` – Start Dev UI for PDF document processing demo. + +## Flows + +Each source file defines flows that can be invoked from the Dev UI or the Genkit CLI: + +### Basic Examples +- `anthropic-stable-hello` – Simple greeting using stable API +- `anthropic-stable-stream` – Streaming response example +- `anthropic-beta-hello` – Simple greeting using beta API +- `anthropic-beta-stream` – Streaming response with beta API + +### Text/Plain Handling +- `stable-text-plain-error` – Demonstrates the helpful error when using text/plain as media +- `stable-text-plain-correct` – Shows the correct way to send text content + +### WEBP Image Handling +- `stable-webp-matching` – WEBP image with matching contentType +- `stable-webp-mismatched` – WEBP image with mismatched contentType (demonstrates the fix) + +### PDF Document Processing +- `stable-pdf-base64` – Process a PDF from a local file using base64 encoding +- `stable-pdf-url` – Process a PDF from a publicly accessible URL +- `stable-pdf-analysis` – Analyze a PDF document for key topics, concepts, and visual elements + +Example: `genkit flow:run anthropic-stable-hello` diff --git a/js/testapps/anthropic-basic/package.json b/js/testapps/anthropic/package.json similarity index 51% rename from js/testapps/anthropic-basic/package.json rename to js/testapps/anthropic/package.json index 5026287bdc..08e1a0d2fd 100644 --- a/js/testapps/anthropic-basic/package.json +++ b/js/testapps/anthropic/package.json @@ -1,18 +1,21 @@ { - "name": "anthropic-basic", - "version": "1.0.0", + "name": "anthropic-testapp", + "version": "0.0.1", "description": "Sample Genkit app showcasing Anthropic plugin stable and beta usage.", - "main": "lib/stable.js", + "main": "lib/stable/basic.js", "scripts": { "build": "tsc", "build:watch": "tsc --watch", - "start:stable": "node lib/stable.js", - "start:beta": "node lib/beta.js", - "dev:stable": "genkit start -- npx tsx --watch src/stable.ts", - "dev:beta": "genkit start -- npx tsx --watch src/beta.ts", + "start:stable": "node lib/stable/basic.js", + "start:beta": "node lib/beta/basic.js", + "dev:stable": "genkit start -- npx tsx --watch src/stable/basic.ts", + "dev:beta": "genkit start -- npx tsx --watch src/beta/basic.ts", + "dev:stable:text-plain": "genkit start -- npx tsx --watch src/stable/text-plain.ts", + "dev:stable:webp": "genkit start -- npx tsx --watch src/stable/webp.ts", + "dev:stable:pdf": "genkit start -- npx tsx --watch src/stable/pdf.ts", "genkit:dev": "cross-env GENKIT_ENV=dev npm run dev:stable", - "genkit:start": "cross-env GENKIT_ENV=dev genkit start -- tsx --watch src/stable.ts", - "dev": "export GENKIT_RUNTIME_ID=$(openssl rand -hex 8) && node lib/stable.js 2>&1" + "genkit:start": "cross-env GENKIT_ENV=dev genkit start -- tsx --watch src/stable/basic.ts", + "dev": "export GENKIT_RUNTIME_ID=$(openssl rand -hex 8) && node lib/stable/basic.js 2>&1" }, "keywords": [ "genkit", diff --git a/js/testapps/anthropic-basic/src/beta.ts b/js/testapps/anthropic/src/beta/basic.ts similarity index 100% rename from js/testapps/anthropic-basic/src/beta.ts rename to js/testapps/anthropic/src/beta/basic.ts diff --git a/js/testapps/anthropic/src/stable/attention-first-page.pdf b/js/testapps/anthropic/src/stable/attention-first-page.pdf new file mode 100644 index 0000000000000000000000000000000000000000..95c6625029083261ad2df5b6c7e0b8f48e3e67b7 GIT binary patch literal 92454 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 { + // Read PDF file from the same directory as this source file + const pdfPath = path.join(__dirname, 'attention-first-page.pdf'); + const pdfBuffer = fs.readFileSync(pdfPath); + const pdfBase64 = pdfBuffer.toString('base64'); + + const { text } = await ai.generate({ + model: claudeSonnet45, + messages: [ + { + role: 'user', + content: [ + { + text: 'What are the key findings or main points in this document?', + }, + { + media: { + url: `data:application/pdf;base64,${pdfBase64}`, + contentType: 'application/pdf', + }, + }, + ], + }, + ], + }); + + return text; +}); + +/** + * This flow demonstrates PDF document processing with a URL reference. + * Note: This requires the PDF to be hosted at a publicly accessible URL. + */ +ai.defineFlow('stable-pdf-url', async () => { + // Example: Using a publicly hosted PDF URL + // In a real application, you would use your own hosted PDF + const pdfUrl = + 'https://assets.anthropic.com/m/1cd9d098ac3e6467/original/Claude-3-Model-Card-October-Addendum.pdf'; + + const { text } = await ai.generate({ + model: claudeSonnet45, + messages: [ + { + role: 'user', + content: [ + { + text: 'Summarize the key points from this document.', + }, + { + media: { + url: pdfUrl, + contentType: 'application/pdf', + }, + }, + ], + }, + ], + }); + + return text; +}); + +/** + * This flow demonstrates analyzing specific aspects of a PDF document. + * Claude can understand both text and visual elements (charts, tables, images) in PDFs. + */ +ai.defineFlow('stable-pdf-analysis', async () => { + const pdfPath = path.join(__dirname, 'attention-first-page.pdf'); + const pdfBuffer = fs.readFileSync(pdfPath); + const pdfBase64 = pdfBuffer.toString('base64'); + + const { text } = await ai.generate({ + model: claudeSonnet45, + messages: [ + { + role: 'user', + content: [ + { + text: 'Analyze this document and provide:\n1. The main topic or subject\n2. Any key technical concepts mentioned\n3. Any visual elements (charts, tables, diagrams) if present', + }, + { + media: { + url: `data:application/pdf;base64,${pdfBase64}`, + contentType: 'application/pdf', + }, + }, + ], + }, + ], + }); + + return text; +}); diff --git a/js/testapps/anthropic/src/stable/text-plain.ts b/js/testapps/anthropic/src/stable/text-plain.ts new file mode 100644 index 0000000000..a7f4c02544 --- /dev/null +++ b/js/testapps/anthropic/src/stable/text-plain.ts @@ -0,0 +1,83 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { anthropic, claudeSonnet45 } from '@genkit-ai/anthropic'; +import { genkit } from 'genkit'; + +const ai = genkit({ + plugins: [anthropic()], +}); + +/** + * This flow demonstrates the error that occurs when trying to use text/plain + * files as media. The plugin will throw a helpful error message guiding users + * to use text content instead. + * + * Error message: "Unsupported media type: text/plain. Text files should be sent + * as text content in the message, not as media. For example, use { text: '...' } + * instead of { media: { url: '...', contentType: 'text/plain' } }" + */ +ai.defineFlow('stable-text-plain-error', async () => { + try { + await ai.generate({ + model: claudeSonnet45, + messages: [ + { + role: 'user', + content: [ + { + media: { + url: 'data:text/plain;base64,SGVsbG8gV29ybGQ=', + contentType: 'text/plain', + }, + }, + ], + }, + ], + }); + return 'Unexpected: Should have thrown an error'; + } catch (error: any) { + return { + error: error.message, + note: 'This demonstrates the helpful error message for text/plain files', + }; + } +}); + +/** + * This flow demonstrates the correct way to send text content. + * Instead of using media with text/plain, use the text field directly. + */ +ai.defineFlow('stable-text-plain-correct', async () => { + // Read the text content (in a real app, you'd read from a file) + const textContent = 'Hello World\n\nThis is a text file content.'; + + const { text } = await ai.generate({ + model: claudeSonnet45, + messages: [ + { + role: 'user', + content: [ + { + text: `Please summarize this text file content:\n\n${textContent}`, + }, + ], + }, + ], + }); + + return text; +}); diff --git a/js/testapps/anthropic/src/stable/webp.ts b/js/testapps/anthropic/src/stable/webp.ts new file mode 100644 index 0000000000..48de8f877d --- /dev/null +++ b/js/testapps/anthropic/src/stable/webp.ts @@ -0,0 +1,95 @@ +/** + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { anthropic, claudeSonnet45 } from '@genkit-ai/anthropic'; +import { genkit } from 'genkit'; + +const ai = genkit({ + plugins: [anthropic()], +}); + +/** + * This flow demonstrates WEBP image handling with matching contentType. + * Both the data URL and the contentType field specify image/webp. + */ +ai.defineFlow('stable-webp-matching', async () => { + // Minimal valid WEBP image (1x1 pixel, transparent) + // In a real app, you'd load an actual WEBP image file + const webpImageData = + 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='; + + const { text } = await ai.generate({ + model: claudeSonnet45, + messages: [ + { + role: 'user', + content: [ + { text: 'Describe this image:' }, + { + media: { + url: webpImageData, + contentType: 'image/webp', + }, + }, + ], + }, + ], + }); + + return text; +}); + +/** + * This flow demonstrates the fix for WEBP images with mismatched contentType. + * Even if contentType says 'image/png', the plugin will use 'image/webp' from + * the data URL, preventing API validation errors. + * + * This fix ensures that the media_type sent to Anthropic matches the actual + * image data, which is critical for WEBP images that were previously causing + * "Image does not match the provided media type" errors. + */ +ai.defineFlow('stable-webp-mismatched', async () => { + // Minimal valid WEBP image (1x1 pixel, transparent) + const webpImageData = + 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='; + + const { text } = await ai.generate({ + model: claudeSonnet45, + messages: [ + { + role: 'user', + content: [ + { + text: 'Describe this image (note: contentType is wrong but data URL is correct):', + }, + { + media: { + // Data URL says WEBP, but contentType says PNG + // The plugin will use WEBP from the data URL (the fix) + url: webpImageData, + contentType: 'image/png', // This mismatch is handled correctly + }, + }, + ], + }, + ], + }); + + return { + result: text, + note: 'The plugin correctly used image/webp from the data URL, not image/png from contentType', + }; +}); diff --git a/js/testapps/anthropic-basic/tsconfig.json b/js/testapps/anthropic/tsconfig.json similarity index 100% rename from js/testapps/anthropic-basic/tsconfig.json rename to js/testapps/anthropic/tsconfig.json From df96b08ba11ec76d794b423e21a8841acfcfadd3 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 17 Nov 2025 16:14:10 +0000 Subject: [PATCH 45/51] docs(js/plugins/anthropic): update code comments and test app --- js/plugins/anthropic/src/index.ts | 14 ++++++++++---- js/testapps/anthropic/README.md | 1 + js/testapps/anthropic/src/beta/basic.ts | 12 ++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 05a882122a..7db1bd8081 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -85,18 +85,24 @@ function getAnthropicClient(options?: PluginOptions): Anthropic { * environment variables. It initializes the Anthropic client and makes available the Claude models for use. * * Exports: + * - claude35Haiku: Reference to the Claude 3.5 Haiku model. * - claude3Haiku: Reference to the Claude 3 Haiku model. - * - claude3Opus: Reference to the Claude 3 Opus model. + * - claudeHaiku45: Reference to the Claude Haiku 4.5 model. + * - claudeOpus4: Reference to the Claude Opus 4 model. + * - claudeOpus41: Reference to the Claude Opus 4.1 model. + * - claudeSonnet4: Reference to the Claude Sonnet 4 model. + * - claudeSonnet45: Reference to the Claude Sonnet 4.5 model. * - anthropic: The main plugin function to interact with the Anthropic AI. * * Usage: - * To use the Claude models, initialize the anthropic plugin inside `configureGenkit` and pass the configuration options. If no API key is provided in the options, the environment variable `ANTHROPIC_API_KEY` must be set. If you want to cache the system prompt, set `cacheSystemPrompt` to `true`. **Note:** Prompt caching is in beta and may change. To learn more, see https://docs.anthropic.com/en/docs/prompt-caching. + * To use the Claude models, initialize the anthropic plugin inside `genkit()` and pass the configuration options. If no API key is provided in the options, the environment variable `ANTHROPIC_API_KEY` must be set. If you want to cache the system prompt, set `cacheSystemPrompt` to `true`. **Note:** Prompt caching is in beta and may change. To learn more, see https://docs.anthropic.com/en/docs/prompt-caching. * * Example: * ``` - * import anthropic from 'genkitx-anthropic'; + * import { anthropic } from '@genkit-ai/anthropic'; + * import { genkit } from 'genkit'; * - * export default configureGenkit({ + * const ai = genkit({ * plugins: [ * anthropic({ apiKey: 'your-api-key', cacheSystemPrompt: false }) * ... // other plugins diff --git a/js/testapps/anthropic/README.md b/js/testapps/anthropic/README.md index 43a00f468c..3343b90ea7 100644 --- a/js/testapps/anthropic/README.md +++ b/js/testapps/anthropic/README.md @@ -49,6 +49,7 @@ Each source file defines flows that can be invoked from the Dev UI or the Genkit - `anthropic-stable-stream` – Streaming response example - `anthropic-beta-hello` – Simple greeting using beta API - `anthropic-beta-stream` – Streaming response with beta API +- `anthropic-beta-opus41` – Test Opus 4.1 model with beta API ### Text/Plain Handling - `stable-text-plain-error` – Demonstrates the helpful error when using text/plain as media diff --git a/js/testapps/anthropic/src/beta/basic.ts b/js/testapps/anthropic/src/beta/basic.ts index 2572b2852e..eca917d2d3 100644 --- a/js/testapps/anthropic/src/beta/basic.ts +++ b/js/testapps/anthropic/src/beta/basic.ts @@ -26,6 +26,7 @@ const ai = genkit({ const betaHaiku = anthropic.model('claude-3-5-haiku', { apiVersion: 'beta' }); const betaSonnet = anthropic.model('claude-4-5-sonnet', { apiVersion: 'beta' }); +const betaOpus41 = anthropic.model('claude-opus-4-1', { apiVersion: 'beta' }); ai.defineFlow('anthropic-beta-hello', async () => { const { text } = await ai.generate({ @@ -62,3 +63,14 @@ ai.defineFlow('anthropic-beta-stream', async (_, { sendChunk }) => { return collected.join(''); }); + +ai.defineFlow('anthropic-beta-opus41', async () => { + const { text } = await ai.generate({ + model: betaOpus41, + prompt: + 'You are Claude Opus 4.1 on the beta API. Provide a brief greeting that confirms you are using the beta API.', + config: { temperature: 0.6 }, + }); + + return text; +}); From 66771ff3b43f37c9d54283335ee58bba1a0580c7 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Mon, 17 Nov 2025 17:46:39 +0000 Subject: [PATCH 46/51] fix(js/plugins/anthropic): use genkit logger and update docs --- js/plugins/anthropic/README.md | 16 +++++++++++++++- js/plugins/anthropic/src/index.ts | 2 +- js/plugins/anthropic/src/runner/beta.ts | 3 ++- js/plugins/anthropic/src/runner/stable.ts | 5 +++-- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 6aa02f9d53..5f535c53ca 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -84,7 +84,21 @@ console.log(response.text); // Final assistant answer console.log(response.reasoning); // Summarized thinking steps ``` -When thinking is enabled, request bodies sent through the plugin include the `thinking` payload (`{ type: 'enabled', budget_tokens: … }`) that Anthropic’s API expects, and streamed responses deliver `reasoning` parts as they arrive so you can render the chain-of-thought incrementally. +When thinking is enabled, request bodies sent through the plugin include the `thinking` payload (`{ type: 'enabled', budget_tokens: … }`) that Anthropic's API expects, and streamed responses deliver `reasoning` parts as they arrive so you can render the chain-of-thought incrementally. + +### Beta API Limitations + +The beta API surface provides access to experimental features, but some server-managed tool blocks are not yet supported by this plugin. The following beta API features will cause an error if encountered: + +- `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` + +Note that `server_tool_use` and `web_search_tool_result` ARE supported and work with both stable and beta APIs. ### Within a flow diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index 7db1bd8081..c5393e3167 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -163,7 +163,7 @@ export type AnthropicPlugin = { /** * Anthropic AI plugin for Genkit. - * Includes Claude models (3, 3.5, 3.7, and 4 series). + * Includes Claude models (3, 3.5, and 4 series). */ export const anthropic = anthropicPlugin as AnthropicPlugin; (anthropic as any).model = ( diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index 85333d246d..ab7ba26707 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -40,6 +40,7 @@ import type { ModelResponseData, Part, } from 'genkit'; +import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; @@ -460,7 +461,7 @@ export class BetaRunner extends BaseRunner { throw new Error(unsupportedServerToolError(contentBlock.type)); } const unknownType = (contentBlock as { type: string }).type; - console.warn( + logger.warn( `Unexpected Anthropic beta content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify( contentBlock )}` diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 7259a23997..2270127605 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -38,6 +38,7 @@ import type { ModelResponseData, Part, } from 'genkit'; +import { logger } from 'genkit/logging'; import { KNOWN_CLAUDE_MODELS } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; @@ -406,7 +407,7 @@ export class Runner extends BaseRunner { default: { const unknownType = (block as { type: string }).type; - console.warn( + logger.warn( `Unexpected Anthropic content block type in stream: ${unknownType}. Returning undefined. Content block: ${JSON.stringify(block)}` ); return undefined; @@ -462,7 +463,7 @@ export class Runner extends BaseRunner { default: { const unknownType = (contentBlock as { type: string }).type; - console.warn( + logger.warn( `Unexpected Anthropic content block type: ${unknownType}. Returning empty text. Content block: ${JSON.stringify(contentBlock)}` ); return { text: '' }; From 74f8539a3ecfbfe7bf9955cdf293e219fbd7bcd8 Mon Sep 17 00:00:00 2001 From: Jacob Cable <32874567+cabljac@users.noreply.github.com> Date: Tue, 25 Nov 2025 16:54:42 +0000 Subject: [PATCH 47/51] refactor(js/plugins/anthropic): refactor model references approach to follow google-genai * refactor(js/plugins/anthropic): refactor model references approach to follow google-genai * refactor(js/plugins/anthropic): remove namespace * refactor(js/testapps/anthropic): update testapps to use .model --- js/plugins/anthropic/README.md | 17 +- js/plugins/anthropic/src/index.ts | 27 +- js/plugins/anthropic/src/list.ts | 22 +- js/plugins/anthropic/src/models.ts | 296 ++++++++++-------- js/plugins/anthropic/src/runner/beta.ts | 6 +- js/plugins/anthropic/src/runner/stable.ts | 6 +- .../anthropic/tests/beta_runner_test.ts | 2 +- js/plugins/anthropic/tests/index_test.ts | 3 +- .../anthropic/tests/stable_runner_test.ts | 10 +- js/testapps/anthropic/src/beta/basic.ts | 2 +- js/testapps/anthropic/src/stable/basic.ts | 6 +- js/testapps/anthropic/src/stable/pdf.ts | 8 +- .../anthropic/src/stable/text-plain.ts | 6 +- js/testapps/anthropic/src/stable/webp.ts | 6 +- 14 files changed, 206 insertions(+), 211 deletions(-) diff --git a/js/plugins/anthropic/README.md b/js/plugins/anthropic/README.md index 5f535c53ca..ec9c9115c0 100644 --- a/js/plugins/anthropic/README.md +++ b/js/plugins/anthropic/README.md @@ -24,12 +24,12 @@ Install the plugin in your project with your favorite package manager: ```typescript import { genkit } from 'genkit'; -import { anthropic, claude35Sonnet } from '@genkit-ai/anthropic'; +import { anthropic } from '@genkit-ai/anthropic'; const ai = genkit({ plugins: [anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })], // specify a default model for generate here if you wish: - model: claude35Sonnet, + model: anthropic.model('claude-sonnet-4-5'), }); ``` @@ -39,7 +39,7 @@ The simplest way to generate text is by using the `generate` method: ```typescript const response = await ai.generate({ - model: claude3Haiku, // model imported from @genkit-ai/anthropic + model: anthropic.model('claude-3-haiku'), prompt: 'Tell me a joke.', }); @@ -130,7 +130,7 @@ The plugin supports Genkit Plugin API v2, which allows you to use models directl import { anthropic } from '@genkit-ai/anthropic'; // Create a model reference directly -const claude = anthropic.model('claude-sonnet-4.5'); +const claude = anthropic.model('claude-sonnet-4-5'); // Use the model directly const response = await claude({ @@ -145,10 +145,15 @@ const response = await claude({ console.log(response); ``` -You can also import pre-configured model references: +You can also create model references using the plugin's `model()` method: ```typescript -import { claudeSonnet45, claudeOpus41, claude35Haiku } from '@genkit-ai/anthropic'; +import { anthropic } from '@genkit-ai/anthropic'; + +// Create model references +const claudeSonnet45 = anthropic.model('claude-sonnet-4-5'); +const claudeOpus41 = anthropic.model('claude-opus-4-1'); +const claude35Haiku = anthropic.model('claude-3-5-haiku'); // Use the model reference directly const response = await claudeSonnet45({ diff --git a/js/plugins/anthropic/src/index.ts b/js/plugins/anthropic/src/index.ts index c5393e3167..d5a0fef9cb 100644 --- a/js/plugins/anthropic/src/index.ts +++ b/js/plugins/anthropic/src/index.ts @@ -28,30 +28,13 @@ import { ClaudeModelName, KNOWN_CLAUDE_MODELS, KnownClaudeModels, - claude35Haiku, - claude3Haiku, - claudeHaiku45, claudeModel, claudeModelReference, - claudeOpus4, - claudeOpus41, - claudeSonnet4, - claudeSonnet45, } from './models.js'; import { InternalPluginOptions, PluginOptions, __testClient } from './types.js'; const PROMPT_CACHING_BETA_HEADER_VALUE = 'prompt-caching-2024-07-31'; -export { - claude35Haiku, - claude3Haiku, - claudeHaiku45, - claudeOpus4, - claudeOpus41, - claudeSonnet4, - claudeSonnet45, -}; - /** * Gets or creates an Anthropic client instance. * Supports test client injection for internal testing. @@ -85,13 +68,6 @@ function getAnthropicClient(options?: PluginOptions): Anthropic { * environment variables. It initializes the Anthropic client and makes available the Claude models for use. * * Exports: - * - claude35Haiku: Reference to the Claude 3.5 Haiku model. - * - claude3Haiku: Reference to the Claude 3 Haiku model. - * - claudeHaiku45: Reference to the Claude Haiku 4.5 model. - * - claudeOpus4: Reference to the Claude Opus 4 model. - * - claudeOpus41: Reference to the Claude Opus 4.1 model. - * - claudeSonnet4: Reference to the Claude Sonnet 4 model. - * - claudeSonnet45: Reference to the Claude Sonnet 4.5 model. * - anthropic: The main plugin function to interact with the Anthropic AI. * * Usage: @@ -108,6 +84,9 @@ function getAnthropicClient(options?: PluginOptions): Anthropic { * ... // other plugins * ] * }); + * + * // Access models via the plugin's model() method: + * const model = anthropic.model('claude-sonnet-4'); * ``` */ function anthropicPlugin(options?: PluginOptions): GenkitPluginV2 { diff --git a/js/plugins/anthropic/src/list.ts b/js/plugins/anthropic/src/list.ts index bafb131844..f04b1cf082 100644 --- a/js/plugins/anthropic/src/list.ts +++ b/js/plugins/anthropic/src/list.ts @@ -19,29 +19,9 @@ import Anthropic from '@anthropic-ai/sdk'; import { modelActionMetadata } from 'genkit/plugin'; import { ActionMetadata, ModelReference, z } from 'genkit'; -import { - GENERIC_CLAUDE_MODEL_INFO, - KNOWN_CLAUDE_MODELS, - claude35Haiku, - claude3Haiku, - claudeHaiku45, - claudeOpus4, - claudeOpus41, - claudeSonnet4, - claudeSonnet45, -} from './models.js'; +import { GENERIC_CLAUDE_MODEL_INFO, KNOWN_CLAUDE_MODELS } from './models.js'; import { AnthropicConfigSchema } from './types.js'; -export { - claude35Haiku, - claude3Haiku, - claudeHaiku45, - claudeOpus4, - claudeOpus41, - claudeSonnet4, - claudeSonnet45, -}; - function normalizeModelId(modelId: string): string { // Strip date suffixes (e.g. "-20241001") or "-latest" so lookups hit canonical keys. return modelId.replace(/-(?:\d{8}|latest)$/i, ''); diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index fcf0daf7d8..ab9f1a198e 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -27,6 +27,7 @@ import type { GenerateResponseChunkData, ModelAction } from 'genkit/model'; import { modelRef } from 'genkit/model'; import { model } from 'genkit/plugin'; +import type { ModelInfo } from 'genkit/model'; import { BetaRunner, Runner } from './runner/index.js'; import { AnthropicBaseConfigSchema, @@ -38,131 +39,51 @@ import { type ClaudeRunnerParams, } from './types.js'; -export const claudeSonnet4 = modelRef({ - name: 'claude-sonnet-4', - namespace: 'anthropic', - info: { - versions: ['claude-sonnet-4-20250514'], - label: 'Anthropic - Claude Sonnet 4', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicThinkingConfigSchema, - version: 'claude-sonnet-4-20250514', -}); - -export const claude3Haiku = modelRef({ - name: 'claude-3-haiku', - namespace: 'anthropic', - info: { - versions: ['claude-3-haiku-20240307'], - label: 'Anthropic - Claude 3 Haiku', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicBaseConfigSchema, - version: 'claude-3-haiku-20240307', -}); - -export const claudeOpus4 = modelRef({ - name: 'claude-opus-4', - namespace: 'anthropic', - info: { - versions: ['claude-opus-4-20250514'], - label: 'Anthropic - Claude Opus 4', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicThinkingConfigSchema, - version: 'claude-opus-4-20250514', -}); - -export const claude35Haiku = modelRef({ - name: 'claude-3-5-haiku', - namespace: 'anthropic', - info: { - versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku'], - label: 'Anthropic - Claude 3.5 Haiku', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicBaseConfigSchema, - version: 'claude-3-5-haiku-latest', -}); - -export const claudeSonnet45 = modelRef({ - name: 'claude-sonnet-4-5', - namespace: 'anthropic', - info: { - versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5'], - label: 'Anthropic - Claude Sonnet 4.5', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicThinkingConfigSchema, - version: 'claude-sonnet-4-5', -}); +// This contains all the Anthropic config schema types +type ConfigSchemaType = + | AnthropicBaseConfigSchemaType + | AnthropicThinkingConfigSchemaType; -export const claudeHaiku45 = modelRef({ - name: 'claude-haiku-4-5', - namespace: 'anthropic', - info: { - versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5'], - label: 'Anthropic - Claude Haiku 4.5', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - configSchema: AnthropicThinkingConfigSchema, - version: 'claude-haiku-4-5', -}); +/** + * Creates a model reference for a Claude model. + * Computes the default version from info.versions array and sets it on the modelRef. + */ +function commonRef( + name: string, + info?: ModelInfo, + configSchema: ConfigSchemaType = AnthropicConfigSchema +): ModelReference { + // Compute default version from info.versions array + let defaultVersion: string | undefined; + if (info?.versions && info.versions.length > 0) { + // Prefer version with '-latest' suffix + const latestVersion = info.versions.find((v) => v.endsWith('-latest')); + if (latestVersion) { + defaultVersion = latestVersion; + } else if (info.versions.includes(name)) { + // If base name exists in versions array, use it directly + defaultVersion = name; + } else { + // Otherwise use first version + defaultVersion = info.versions[0]; + } + } -export const claudeOpus41 = modelRef({ - name: 'claude-opus-4-1', - namespace: 'anthropic', - info: { - versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1'], - label: 'Anthropic - Claude Opus 4.1', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], + return modelRef({ + name: `anthropic/${name}`, + configSchema, + version: defaultVersion, + info: info ?? { + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, }, - }, - configSchema: AnthropicThinkingConfigSchema, - version: 'claude-opus-4-1', -}); + }); +} export const KNOWN_CLAUDE_MODELS: Record< string, @@ -170,15 +91,127 @@ export const KNOWN_CLAUDE_MODELS: Record< AnthropicBaseConfigSchemaType | AnthropicThinkingConfigSchemaType > > = { - 'claude-3-haiku': claude3Haiku, - 'claude-3-5-haiku': claude35Haiku, - 'claude-sonnet-4': claudeSonnet4, - 'claude-opus-4': claudeOpus4, - 'claude-sonnet-4-5': claudeSonnet45, - 'claude-haiku-4-5': claudeHaiku45, - 'claude-opus-4-1': claudeOpus41, + 'claude-3-haiku': commonRef( + 'claude-3-haiku', + { + versions: ['claude-3-haiku-20240307'], + label: 'Anthropic - Claude 3 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + AnthropicBaseConfigSchema + ), + 'claude-3-5-haiku': commonRef( + 'claude-3-5-haiku', + { + versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku'], + label: 'Anthropic - Claude 3.5 Haiku', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + AnthropicBaseConfigSchema + ), + 'claude-sonnet-4': commonRef( + 'claude-sonnet-4', + { + versions: ['claude-sonnet-4-20250514'], + label: 'Anthropic - Claude Sonnet 4', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + AnthropicThinkingConfigSchema + ), + 'claude-opus-4': commonRef( + 'claude-opus-4', + { + versions: ['claude-opus-4-20250514'], + label: 'Anthropic - Claude Opus 4', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + AnthropicThinkingConfigSchema + ), + 'claude-sonnet-4-5': commonRef( + 'claude-sonnet-4-5', + { + versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5'], + label: 'Anthropic - Claude Sonnet 4.5', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + AnthropicThinkingConfigSchema + ), + 'claude-haiku-4-5': commonRef( + 'claude-haiku-4-5', + { + versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5'], + label: 'Anthropic - Claude Haiku 4.5', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + AnthropicThinkingConfigSchema + ), + 'claude-opus-4-1': commonRef( + 'claude-opus-4-1', + { + versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1'], + label: 'Anthropic - Claude Opus 4.1', + supports: { + multiturn: true, + tools: true, + media: true, + systemRole: true, + output: ['text'], + }, + }, + AnthropicThinkingConfigSchema + ), }; +/** + * Gets the un-prefixed model name from a modelReference. + */ +export function extractVersion( + model: ModelReference | undefined, + modelName: string +): string { + if (model?.version) { + return model.version; + } + // Fallback: extract from model name (remove 'anthropic/' prefix if present) + return modelName.replace(/^anthropic\//, ''); +} + /** * Generic Claude model info for unknown/unsupported models. * Used when a model name is not in KNOWN_CLAUDE_MODELS. @@ -264,17 +297,16 @@ export function claudeModelReference( if (knownModel) { return modelRef({ name: knownModel.name, - namespace: 'anthropic', info: knownModel.info, configSchema: knownModel.configSchema, + version: knownModel.version, config, }); } // For unknown models, create a basic reference return modelRef({ - name, - namespace: 'anthropic', + name: `anthropic/${name}`, configSchema: AnthropicConfigSchema, config, }); diff --git a/js/plugins/anthropic/src/runner/beta.ts b/js/plugins/anthropic/src/runner/beta.ts index ab7ba26707..6a71fa71d5 100644 --- a/js/plugins/anthropic/src/runner/beta.ts +++ b/js/plugins/anthropic/src/runner/beta.ts @@ -42,7 +42,7 @@ import type { } from 'genkit'; import { logger } from 'genkit/logging'; -import { KNOWN_CLAUDE_MODELS } from '../models.js'; +import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { BaseRunner } from './base.js'; import { RunnerTypes } from './types.js'; @@ -233,7 +233,7 @@ export class BetaRunner extends BaseRunner { const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = - request.config?.version ?? model?.version ?? modelName; + request.config?.version ?? extractVersion(model, modelName); let betaSystem: BetaMessageCreateParamsNonStreaming['system']; @@ -301,7 +301,7 @@ export class BetaRunner extends BaseRunner { const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = - request.config?.version ?? model?.version ?? modelName; + request.config?.version ?? extractVersion(model, modelName); const betaSystem = system === undefined diff --git a/js/plugins/anthropic/src/runner/stable.ts b/js/plugins/anthropic/src/runner/stable.ts index 2270127605..0c8f7ffc4f 100644 --- a/js/plugins/anthropic/src/runner/stable.ts +++ b/js/plugins/anthropic/src/runner/stable.ts @@ -40,7 +40,7 @@ import type { } from 'genkit'; import { logger } from 'genkit/logging'; -import { KNOWN_CLAUDE_MODELS } from '../models.js'; +import { KNOWN_CLAUDE_MODELS, extractVersion } from '../models.js'; import { AnthropicConfigSchema, type ClaudeRunnerParams } from '../types.js'; import { BaseRunner } from './base.js'; import { RunnerTypes as BaseRunnerTypes } from './types.js'; @@ -182,7 +182,7 @@ export class Runner extends BaseRunner { const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = - request.config?.version ?? model?.version ?? modelName; + request.config?.version ?? extractVersion(model, modelName); const systemValue = system === undefined @@ -252,7 +252,7 @@ export class Runner extends BaseRunner { const model = KNOWN_CLAUDE_MODELS[modelName]; const { system, messages } = this.toAnthropicMessages(request.messages); const mappedModelName = - request.config?.version ?? model?.version ?? modelName; + request.config?.version ?? extractVersion(model, modelName); const systemValue = system === undefined diff --git a/js/plugins/anthropic/tests/beta_runner_test.ts b/js/plugins/anthropic/tests/beta_runner_test.ts index d9e9c93479..655bfc599e 100644 --- a/js/plugins/anthropic/tests/beta_runner_test.ts +++ b/js/plugins/anthropic/tests/beta_runner_test.ts @@ -499,7 +499,7 @@ describe('BetaRunner', () => { true ); - assert.strictEqual(body.model, 'claude-3-5-haiku-latest'); + assert.strictEqual(body.model, 'claude-3-5-haiku'); assert.ok(Array.isArray(body.system)); assert.strictEqual(body.max_tokens, 128); assert.strictEqual(body.top_k, 4); diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index f5234250ba..0d9a6358c1 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -266,14 +266,13 @@ describe('Anthropic resolve helpers', () => { assert.strictEqual(typeof action, 'function'); }); - it('anthropic.model should return namespaced reference with config', () => { + it('anthropic.model should return model reference with config', () => { const reference = anthropic.model('claude-3-5-haiku', { temperature: 0.25, }); const referenceAny = reference as any; assert.ok(referenceAny, 'Model reference should be created'); - assert.strictEqual(referenceAny.namespace, 'anthropic'); assert.ok(referenceAny.name.includes('claude-3-5-haiku')); assert.strictEqual(referenceAny.config?.temperature, 0.25); }); diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index e1ed20ff7e..91151b9a13 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -969,7 +969,7 @@ describe('toAnthropicRequestBody', () => { role: 'user', }, ], - model: 'claude-3-5-haiku-latest', + model: 'claude-3-5-haiku', metadata: { user_id: 'exampleUser123', }, @@ -1220,7 +1220,7 @@ describe('toAnthropicStreamingRequestBody', () => { ); assert.strictEqual(output.stream, true); - assert.strictEqual(output.model, 'claude-3-5-haiku-latest'); + assert.strictEqual(output.model, 'claude-3-5-haiku'); assert.strictEqual(output.max_tokens, 4096); }); @@ -1291,7 +1291,7 @@ describe('claudeRunner', () => { assert.strictEqual(createStub.mock.calls.length, 1); assert.deepStrictEqual(createStub.mock.calls[0].arguments, [ { - model: 'claude-3-5-haiku-latest', + model: 'claude-3-5-haiku', max_tokens: 4096, messages: [], }, @@ -1342,7 +1342,7 @@ describe('claudeRunner', () => { assert.strictEqual(streamStub.mock.calls.length, 1); assert.deepStrictEqual(streamStub.mock.calls[0].arguments, [ { - model: 'claude-3-5-haiku-latest', + model: 'claude-3-5-haiku', max_tokens: 4096, messages: [], stream: true, @@ -2203,7 +2203,7 @@ describe('Runner request bodies and error branches', () => { true ); - assert.strictEqual(body.model, 'claude-3-5-haiku-latest'); + assert.strictEqual(body.model, 'claude-3-5-haiku'); assert.ok(Array.isArray(body.system)); assert.strictEqual(body.system?.[0].cache_control?.type, 'ephemeral'); assert.strictEqual(body.max_tokens, 256); diff --git a/js/testapps/anthropic/src/beta/basic.ts b/js/testapps/anthropic/src/beta/basic.ts index eca917d2d3..d1309b3400 100644 --- a/js/testapps/anthropic/src/beta/basic.ts +++ b/js/testapps/anthropic/src/beta/basic.ts @@ -25,7 +25,7 @@ const ai = genkit({ }); const betaHaiku = anthropic.model('claude-3-5-haiku', { apiVersion: 'beta' }); -const betaSonnet = anthropic.model('claude-4-5-sonnet', { apiVersion: 'beta' }); +const betaSonnet = anthropic.model('claude-sonnet-4-5', { apiVersion: 'beta' }); const betaOpus41 = anthropic.model('claude-opus-4-1', { apiVersion: 'beta' }); ai.defineFlow('anthropic-beta-hello', async () => { diff --git a/js/testapps/anthropic/src/stable/basic.ts b/js/testapps/anthropic/src/stable/basic.ts index 9390a86ea6..246a42539a 100644 --- a/js/testapps/anthropic/src/stable/basic.ts +++ b/js/testapps/anthropic/src/stable/basic.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { anthropic, claudeSonnet45 } from '@genkit-ai/anthropic'; +import { anthropic } from '@genkit-ai/anthropic'; import { genkit } from 'genkit'; const ai = genkit({ @@ -26,7 +26,7 @@ const ai = genkit({ ai.defineFlow('anthropic-stable-hello', async () => { const { text } = await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), prompt: 'You are a friendly Claude assistant. Greet the user briefly.', }); @@ -35,7 +35,7 @@ ai.defineFlow('anthropic-stable-hello', async () => { ai.defineFlow('anthropic-stable-stream', async (_, { sendChunk }) => { const { stream } = ai.generateStream({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), prompt: 'Compose a short limerick about using Genkit with Anthropic.', }); diff --git a/js/testapps/anthropic/src/stable/pdf.ts b/js/testapps/anthropic/src/stable/pdf.ts index 8952c03315..8953dff696 100644 --- a/js/testapps/anthropic/src/stable/pdf.ts +++ b/js/testapps/anthropic/src/stable/pdf.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { anthropic, claudeSonnet45 } from '@genkit-ai/anthropic'; +import { anthropic } from '@genkit-ai/anthropic'; import * as fs from 'fs'; import { genkit } from 'genkit'; import * as path from 'path'; @@ -34,7 +34,7 @@ ai.defineFlow('stable-pdf-base64', async () => { const pdfBase64 = pdfBuffer.toString('base64'); const { text } = await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), messages: [ { role: 'user', @@ -67,7 +67,7 @@ ai.defineFlow('stable-pdf-url', async () => { 'https://assets.anthropic.com/m/1cd9d098ac3e6467/original/Claude-3-Model-Card-October-Addendum.pdf'; const { text } = await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), messages: [ { role: 'user', @@ -99,7 +99,7 @@ ai.defineFlow('stable-pdf-analysis', async () => { const pdfBase64 = pdfBuffer.toString('base64'); const { text } = await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), messages: [ { role: 'user', diff --git a/js/testapps/anthropic/src/stable/text-plain.ts b/js/testapps/anthropic/src/stable/text-plain.ts index a7f4c02544..0b290d53e6 100644 --- a/js/testapps/anthropic/src/stable/text-plain.ts +++ b/js/testapps/anthropic/src/stable/text-plain.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { anthropic, claudeSonnet45 } from '@genkit-ai/anthropic'; +import { anthropic } from '@genkit-ai/anthropic'; import { genkit } from 'genkit'; const ai = genkit({ @@ -33,7 +33,7 @@ const ai = genkit({ ai.defineFlow('stable-text-plain-error', async () => { try { await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), messages: [ { role: 'user', @@ -66,7 +66,7 @@ ai.defineFlow('stable-text-plain-correct', async () => { const textContent = 'Hello World\n\nThis is a text file content.'; const { text } = await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), messages: [ { role: 'user', diff --git a/js/testapps/anthropic/src/stable/webp.ts b/js/testapps/anthropic/src/stable/webp.ts index 48de8f877d..f8a861024b 100644 --- a/js/testapps/anthropic/src/stable/webp.ts +++ b/js/testapps/anthropic/src/stable/webp.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { anthropic, claudeSonnet45 } from '@genkit-ai/anthropic'; +import { anthropic } from '@genkit-ai/anthropic'; import { genkit } from 'genkit'; const ai = genkit({ @@ -32,7 +32,7 @@ ai.defineFlow('stable-webp-matching', async () => { 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='; const { text } = await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), messages: [ { role: 'user', @@ -67,7 +67,7 @@ ai.defineFlow('stable-webp-mismatched', async () => { 'data:image/webp;base64,UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA='; const { text } = await ai.generate({ - model: claudeSonnet45, + model: anthropic.model('claude-sonnet-4-5'), messages: [ { role: 'user', From 00cd6659dffbe4ac4f7ad0ad696bc5221d8c36f3 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Wed, 26 Nov 2025 10:01:09 +0000 Subject: [PATCH 48/51] refactor(js/testapps/anthropic): fix commonRef and listActions --- js/plugins/anthropic/src/list.ts | 188 +++--------------- js/plugins/anthropic/src/models.ts | 172 ++-------------- js/plugins/anthropic/tests/index_test.ts | 45 +---- .../anthropic/tests/stable_runner_test.ts | 19 +- 4 files changed, 72 insertions(+), 352 deletions(-) diff --git a/js/plugins/anthropic/src/list.ts b/js/plugins/anthropic/src/list.ts index f04b1cf082..6124fd4392 100644 --- a/js/plugins/anthropic/src/list.ts +++ b/js/plugins/anthropic/src/list.ts @@ -18,130 +18,14 @@ import Anthropic from '@anthropic-ai/sdk'; import { modelActionMetadata } from 'genkit/plugin'; -import { ActionMetadata, ModelReference, z } from 'genkit'; -import { GENERIC_CLAUDE_MODEL_INFO, KNOWN_CLAUDE_MODELS } from './models.js'; -import { AnthropicConfigSchema } from './types.js'; - -function normalizeModelId(modelId: string): string { - // Strip date suffixes (e.g. "-20241001") or "-latest" so lookups hit canonical keys. - return modelId.replace(/-(?:\d{8}|latest)$/i, ''); -} - -type ModelMetadataParams = Parameters[0]; - -interface MergeKnownModelMetadataParams { - modelId: string; - ref: ModelReference; - metadataByName: Map; - orderedNames: string[]; -} -/** - * Integrates metadata for a known Claude model into the aggregated list. - * - * It merges version information collected from the Anthropic API onto the - * canonical model definition while preserving any additional metadata that - * may already exist in the accumulator. - */ -function mergeKnownModelMetadata({ - modelId, - ref, - metadataByName, - orderedNames, -}: MergeKnownModelMetadataParams): void { - // Merge onto any prior metadata we have for this named model. - const existing = metadataByName.get(ref.name); - const priorInfo = existing?.info ?? ref.info ?? {}; - const priorVersions = Array.isArray(priorInfo.versions) - ? priorInfo.versions - : (ref.info?.versions ?? []); - - // Track every concrete model ID surfaced by the API so they appear as selectable versions. - const versions = new Set(priorVersions); - versions.add(modelId); - - metadataByName.set(ref.name, { - name: ref.name, - info: { - ...priorInfo, - versions: Array.from(versions), - }, - configSchema: ref.configSchema, - }); - - if (!existing) { - // Preserve the discovery order for determinism. - orderedNames.push(ref.name); - } -} - -interface MergeFallbackModelMetadataParams { - modelId: string; - normalizedId: string; - displayName?: string; - metadataByName: Map; - orderedNames: string[]; -} - -/** - * Creates or updates metadata entries for Anthropic models that are not - * explicitly enumerated in `KNOWN_CLAUDE_MODELS`. - * - * The resulting metadata uses a generic Claude descriptor while capturing - * the specific model ID returned by the API so it can be surfaced in the - * Genkit UI. - */ -function mergeFallbackModelMetadata({ - modelId, - normalizedId, - displayName, - metadataByName, - orderedNames, -}: MergeFallbackModelMetadataParams): void { - const fallbackName = `anthropic/${modelId}`; - const existing = metadataByName.get(fallbackName); - const fallbackLabel = - displayName ?? - `Anthropic - ${normalizedId !== modelId ? normalizedId : modelId}`; - - if (existing) { - const priorVersions = existing.info?.versions ?? []; - const versions = new Set( - Array.isArray(priorVersions) ? priorVersions : [] - ); - versions.add(modelId); - - metadataByName.set(fallbackName, { - ...existing, - info: { - ...existing.info, - versions: Array.from(versions), - }, - }); - return; - } - - metadataByName.set(fallbackName, { - name: fallbackName, - info: { - ...GENERIC_CLAUDE_MODEL_INFO, - label: fallbackLabel, - versions: modelId ? [modelId] : [...GENERIC_CLAUDE_MODEL_INFO.versions], - supports: { - ...GENERIC_CLAUDE_MODEL_INFO.supports, - output: [...GENERIC_CLAUDE_MODEL_INFO.supports.output], - }, - }, - configSchema: AnthropicConfigSchema, - }); - orderedNames.push(fallbackName); -} +import { ActionMetadata } from 'genkit'; +import { claudeModelReference } from './models.js'; /** * Retrieves available Anthropic models from the API and converts them into Genkit action metadata. * - * This function queries the Anthropic API for the list of available models, matches them against - * known Claude models, and generates metadata for both known and unknown models. The resulting - * metadata includes version information and configuration schemas suitable for use in Genkit. + * This function queries the Anthropic API for the list of available models and generates metadata + * for all discovered models. * * @param client - The Anthropic API client instance * @returns A promise that resolves to an array of action metadata for all discovered models @@ -150,43 +34,33 @@ export async function listActions( client: Anthropic ): Promise { const clientModels = (await client.models.list()).data; - const metadataByName = new Map(); - const orderedNames: string[] = []; - - for (const modelInfo of clientModels) { - const modelId = modelInfo.id; - if (!modelId) { - continue; - } - - const normalizedId = normalizeModelId(modelId); - const ref = KNOWN_CLAUDE_MODELS[normalizedId]; - - if (ref) { - mergeKnownModelMetadata({ - modelId, - ref, - metadataByName, - orderedNames, + const seenNames = new Set(); + + return clientModels + .filter((modelInfo) => { + const modelId = modelInfo.id; + if (!modelId) { + return false; + } + + const ref = claudeModelReference(modelId); + const name = ref.name; + + // Deduplicate by name + if (seenNames.has(name)) { + return false; + } + seenNames.add(name); + return true; + }) + .map((modelInfo) => { + const modelId = modelInfo.id!; + const ref = claudeModelReference(modelId); + + return modelActionMetadata({ + name: ref.name, + info: ref.info, + configSchema: ref.configSchema, }); - continue; - } - - // For models we don't explicitly track, synthesize a generic entry that still surfaces the ID. - mergeFallbackModelMetadata({ - modelId, - normalizedId, - displayName: modelInfo.display_name ?? undefined, - metadataByName, - orderedNames, }); - } - - return orderedNames.map((name) => { - const metadata = metadataByName.get(name); - if (!metadata) { - throw new Error(`Missing metadata for model: ${name}`); - } - return modelActionMetadata(metadata); - }); } diff --git a/js/plugins/anthropic/src/models.ts b/js/plugins/anthropic/src/models.ts index ab9f1a198e..2ee33d933c 100644 --- a/js/plugins/anthropic/src/models.ts +++ b/js/plugins/anthropic/src/models.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import type Anthropic from '@anthropic-ai/sdk'; import type { GenerateRequest, GenerateResponseData, @@ -46,33 +45,15 @@ type ConfigSchemaType = /** * Creates a model reference for a Claude model. - * Computes the default version from info.versions array and sets it on the modelRef. */ function commonRef( name: string, - info?: ModelInfo, - configSchema: ConfigSchemaType = AnthropicConfigSchema + configSchema: ConfigSchemaType = AnthropicConfigSchema, + info?: ModelInfo ): ModelReference { - // Compute default version from info.versions array - let defaultVersion: string | undefined; - if (info?.versions && info.versions.length > 0) { - // Prefer version with '-latest' suffix - const latestVersion = info.versions.find((v) => v.endsWith('-latest')); - if (latestVersion) { - defaultVersion = latestVersion; - } else if (info.versions.includes(name)) { - // If base name exists in versions array, use it directly - defaultVersion = name; - } else { - // Otherwise use first version - defaultVersion = info.versions[0]; - } - } - return modelRef({ name: `anthropic/${name}`, configSchema, - version: defaultVersion, info: info ?? { supports: { multiturn: true, @@ -91,109 +72,23 @@ export const KNOWN_CLAUDE_MODELS: Record< AnthropicBaseConfigSchemaType | AnthropicThinkingConfigSchemaType > > = { - 'claude-3-haiku': commonRef( - 'claude-3-haiku', - { - versions: ['claude-3-haiku-20240307'], - label: 'Anthropic - Claude 3 Haiku', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - AnthropicBaseConfigSchema - ), - 'claude-3-5-haiku': commonRef( - 'claude-3-5-haiku', - { - versions: ['claude-3-5-haiku-20241022', 'claude-3-5-haiku'], - label: 'Anthropic - Claude 3.5 Haiku', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - AnthropicBaseConfigSchema - ), + 'claude-3-haiku': commonRef('claude-3-haiku', AnthropicBaseConfigSchema), + 'claude-3-5-haiku': commonRef('claude-3-5-haiku', AnthropicBaseConfigSchema), 'claude-sonnet-4': commonRef( 'claude-sonnet-4', - { - versions: ['claude-sonnet-4-20250514'], - label: 'Anthropic - Claude Sonnet 4', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, - AnthropicThinkingConfigSchema - ), - 'claude-opus-4': commonRef( - 'claude-opus-4', - { - versions: ['claude-opus-4-20250514'], - label: 'Anthropic - Claude Opus 4', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, AnthropicThinkingConfigSchema ), + 'claude-opus-4': commonRef('claude-opus-4', AnthropicThinkingConfigSchema), 'claude-sonnet-4-5': commonRef( 'claude-sonnet-4-5', - { - versions: ['claude-sonnet-4-5-20250929', 'claude-sonnet-4-5'], - label: 'Anthropic - Claude Sonnet 4.5', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, AnthropicThinkingConfigSchema ), 'claude-haiku-4-5': commonRef( 'claude-haiku-4-5', - { - versions: ['claude-haiku-4-5-20251001', 'claude-haiku-4-5'], - label: 'Anthropic - Claude Haiku 4.5', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, AnthropicThinkingConfigSchema ), 'claude-opus-4-1': commonRef( 'claude-opus-4-1', - { - versions: ['claude-opus-4-1-20250805', 'claude-opus-4-1'], - label: 'Anthropic - Claude Opus 4.1', - supports: { - multiturn: true, - tools: true, - media: true, - systemRole: true, - output: ['text'], - }, - }, AnthropicThinkingConfigSchema ), }; @@ -205,10 +100,7 @@ export function extractVersion( model: ModelReference | undefined, modelName: string ): string { - if (model?.version) { - return model.version; - } - // Fallback: extract from model name (remove 'anthropic/' prefix if present) + // Extract from model name (remove 'anthropic/' prefix if present) return modelName.replace(/^anthropic\//, ''); } @@ -217,8 +109,6 @@ export function extractVersion( * Used when a model name is not in KNOWN_CLAUDE_MODELS. */ export const GENERIC_CLAUDE_MODEL_INFO = { - versions: [], - label: 'Anthropic - Claude', supports: { multiturn: true, tools: true, @@ -285,30 +175,29 @@ export function claudeRunner( }; } +/** + * Strips the 'anthropic/' namespace prefix if present. + */ +function checkModelName(name: string): string { + return name.startsWith('anthropic/') ? name.slice(10) : name; +} + /** * Creates a model reference for a Claude model. * This allows referencing models without initializing the plugin. */ export function claudeModelReference( name: string, - config?: z.infer + config: z.infer = {} ): ModelReference { - const knownModel = KNOWN_CLAUDE_MODELS[name]; - if (knownModel) { - return modelRef({ - name: knownModel.name, - info: knownModel.info, - configSchema: knownModel.configSchema, - version: knownModel.version, - config, - }); - } - - // For unknown models, create a basic reference + const modelName = checkModelName(name); return modelRef({ - name: `anthropic/${name}`, + name: `anthropic/${modelName}`, + config: config, configSchema: AnthropicConfigSchema, - config, + info: { + ...GENERIC_CLAUDE_MODEL_INFO, + }, }); } @@ -318,27 +207,8 @@ export function claudeModelReference( * for better defaults; otherwise creates a generic model reference. */ export function claudeModel( - paramsOrName: ClaudeModelParams | string, - client?: Anthropic, - cacheSystemPrompt?: boolean, - defaultApiVersion?: 'stable' | 'beta' + params: ClaudeModelParams ): ModelAction { - const params = - typeof paramsOrName === 'string' - ? { - name: paramsOrName, - client: - client ?? - (() => { - throw new Error( - 'Anthropic client is required to create a model action' - ); - })(), - cacheSystemPrompt, - defaultApiVersion, - } - : paramsOrName; - const { name, client: runnerClient, diff --git a/js/plugins/anthropic/tests/index_test.ts b/js/plugins/anthropic/tests/index_test.ts index 0d9a6358c1..62ef06b5fc 100644 --- a/js/plugins/anthropic/tests/index_test.ts +++ b/js/plugins/anthropic/tests/index_test.ts @@ -146,22 +146,22 @@ describe('Anthropic Plugin', () => { assert.ok(models.length > 0, 'Should return at least one model'); const names = models.map((model) => model.name).sort(); + // Models are listed with their full IDs from the API (no normalization) assert.ok( - names.includes('anthropic/claude-3-5-haiku'), - 'Known model should be listed once with normalized name' + names.includes('anthropic/claude-3-5-haiku-20241022'), + 'Known model should be listed with full model ID from API' ); - assert.strictEqual( - names.filter((name) => name === 'anthropic/claude-3-5-haiku').length, - 1, - 'Known model entries should be deduplicated' + assert.ok( + names.includes('anthropic/claude-3-5-haiku-latest'), + 'Latest variant should be listed separately' ); assert.ok( names.includes('anthropic/claude-3-5-sonnet-20241022'), 'Unknown Claude 3.5 Sonnet should be listed with full model ID' ); assert.ok( - names.includes('anthropic/claude-sonnet-4'), - 'Known Claude Sonnet 4 model should be listed' + names.includes('anthropic/claude-sonnet-4-20250514'), + 'Known Claude Sonnet 4 model should be listed with full model ID' ); assert.ok( names.includes('anthropic/claude-new-5-20251212'), @@ -173,44 +173,21 @@ describe('Anthropic Plugin', () => { ); const haikuMetadata = models.find( - (model) => model.name === 'anthropic/claude-3-5-haiku' + (model) => model.name === 'anthropic/claude-3-5-haiku-20241022' ); assert.ok(haikuMetadata, 'Haiku metadata should exist'); const haikuInfo = getModelInfo(haikuMetadata); assert.ok(haikuInfo, 'Haiku model info should exist'); - assert.ok( - haikuInfo?.versions?.includes('claude-3-5-haiku-20241022'), - 'Known versions should include dated identifier' - ); - assert.ok( - haikuInfo?.versions?.includes('claude-3-5-haiku-latest'), - 'Additional variants should be merged into versions' - ); const newModelMetadata = models.find( (model) => model.name === 'anthropic/claude-new-5-20251212' ); - const newModelInfo = getModelInfo(newModelMetadata); - assert.strictEqual( - newModelInfo?.label, - 'Claude New 5', - 'Unknown models should preserve display name as label' - ); + assert.ok(newModelMetadata, 'New model metadata should exist'); const experimentalMetadata = models.find( (model) => model.name === 'anthropic/claude-experimental-latest' ); - const experimentalInfo = getModelInfo(experimentalMetadata); - assert.strictEqual( - experimentalInfo?.label, - 'Anthropic - claude-experimental', - 'Unknown latest variants should derive fallback label from normalized id' - ); - assert.deepStrictEqual( - experimentalInfo?.versions, - ['claude-experimental-latest'], - 'Unknown models should capture version identifiers' - ); + assert.ok(experimentalMetadata, 'Experimental model metadata should exist'); // Verify mock was called const listStub = mockClient.models.list as any; diff --git a/js/plugins/anthropic/tests/stable_runner_test.ts b/js/plugins/anthropic/tests/stable_runner_test.ts index 91151b9a13..9b60084b3e 100644 --- a/js/plugins/anthropic/tests/stable_runner_test.ts +++ b/js/plugins/anthropic/tests/stable_runner_test.ts @@ -1003,7 +1003,7 @@ describe('toAnthropicRequestBody', () => { role: 'user', }, ], - model: 'claude-3-haiku-20240307', + model: 'claude-3-haiku', metadata: { user_id: 'exampleUser123', }, @@ -1614,17 +1614,13 @@ describe('claudeModel', () => { ); }); - it('should throw when client is omitted in params object', () => { - assert.throws( - () => claudeModel('claude-3-5-haiku'), - /Anthropic client is required to create a model action/ - ); - }); - it('should correctly define supported Claude models', () => { const mockClient = createMockAnthropicClient(); const modelName = 'claude-3-5-haiku'; - const modelAction = claudeModel(modelName, mockClient); + const modelAction = claudeModel({ + name: modelName, + client: mockClient, + }); // Verify the model action is returned assert.ok(modelAction); @@ -1633,7 +1629,10 @@ describe('claudeModel', () => { it('should accept any model name and create a model action', () => { // Following Google GenAI pattern: accept any model name, let API validate - const modelAction = claudeModel('unsupported-model', {} as Anthropic); + const modelAction = claudeModel({ + name: 'unsupported-model', + client: {} as Anthropic, + }); assert.ok(modelAction, 'Should create model action for any model name'); assert.strictEqual(typeof modelAction, 'function'); }); From 4c86cda88b21d7c469bb3ed8d5e9206be8c8542a Mon Sep 17 00:00:00 2001 From: Jacob Cable <32874567+cabljac@users.noreply.github.com> Date: Wed, 26 Nov 2025 16:39:50 +0000 Subject: [PATCH 49/51] Update js/plugins/anthropic/package.json Co-authored-by: Pavel Jbanov --- js/plugins/anthropic/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/plugins/anthropic/package.json b/js/plugins/anthropic/package.json index e3c3e5f036..32b1c4ba87 100644 --- a/js/plugins/anthropic/package.json +++ b/js/plugins/anthropic/package.json @@ -16,7 +16,7 @@ "genai", "generative-ai" ], - "version": "1.21.0", + "version": "1.23.0", "type": "commonjs", "repository": { "type": "git", From 0d3f6951d693cbf2ac7a56cb889fd4f9f71bdb61 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Wed, 26 Nov 2025 16:53:08 +0000 Subject: [PATCH 50/51] refactor(js/plugins/anthropic): remove dedicated workflow --- .github/workflows/anthropic-plugin-tests.yml | 56 -------------------- 1 file changed, 56 deletions(-) delete mode 100644 .github/workflows/anthropic-plugin-tests.yml diff --git a/.github/workflows/anthropic-plugin-tests.yml b/.github/workflows/anthropic-plugin-tests.yml deleted file mode 100644 index 93507d5eb3..0000000000 --- a/.github/workflows/anthropic-plugin-tests.yml +++ /dev/null @@ -1,56 +0,0 @@ -# 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. -# -# SPDX-License-Identifier: Apache-2.0 - -name: Anthropic Plugin Tests - -on: pull_request - -env: - GITHUB_PULL_REQUEST_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - GITHUB_PULL_REQUEST_BASE_SHA: ${{ github.event.pull_request.base.sha }} - -jobs: - test-anthropic-plugin: - name: Run Anthropic Plugin Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v3 - - name: Set up node v20 - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: pnpm - - name: Install dependencies - run: | - cd js - pnpm install - - name: Build core dependencies - run: | - cd js - pnpm -r --workspace-concurrency 1 -F core -F ai build - - name: Build genkit - run: | - cd js - pnpm -F genkit build - - name: Build Anthropic plugin - run: | - cd js/plugins/anthropic - pnpm build - - name: Run Anthropic plugin tests - run: | - cd js/plugins/anthropic - pnpm test From 00182da85caf1cc13d0d9edac02c174e28fa67a0 Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Wed, 26 Nov 2025 17:10:07 +0000 Subject: [PATCH 51/51] chore(js): regen lockfile to fix builds --- js/pnpm-lock.yaml | 139 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 131 insertions(+), 8 deletions(-) diff --git a/js/pnpm-lock.yaml b/js/pnpm-lock.yaml index c1918c9103..109b07c772 100644 --- a/js/pnpm-lock.yaml +++ b/js/pnpm-lock.yaml @@ -257,6 +257,37 @@ importers: specifier: ^4.9.0 version: 4.9.5 + plugins/anthropic: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.68.0 + version: 0.68.0(zod@3.25.67) + devDependencies: + '@types/node': + specifier: ^20.11.16 + version: 20.19.1 + check-node-version: + specifier: ^4.2.1 + version: 4.2.1 + genkit: + specifier: workspace:* + version: link:../../genkit + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 + tsup: + specifier: ^8.3.5 + version: 8.5.0(postcss@8.4.47)(tsx@4.20.3)(typescript@4.9.5)(yaml@2.8.0) + tsx: + specifier: ^4.19.2 + version: 4.20.3 + typescript: + specifier: ^4.9.0 + version: 4.9.5 + plugins/checks: dependencies: '@genkit-ai/ai': @@ -994,6 +1025,25 @@ importers: specifier: '>=12.2' version: 13.4.0(encoding@0.1.13) + testapps/anthropic: + dependencies: + '@genkit-ai/anthropic': + specifier: workspace:* + version: link:../../plugins/anthropic + genkit: + specifier: workspace:* + version: link:../../genkit + devDependencies: + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + tsx: + specifier: ^4.19.2 + version: 4.20.3 + typescript: + specifier: ^5.6.2 + version: 5.8.3 + testapps/basic-gemini: dependencies: '@genkit-ai/firebase': @@ -2042,6 +2092,15 @@ packages: '@anthropic-ai/sdk@0.24.3': resolution: {integrity: sha512-916wJXO6T6k8R6BAAcLhLPv/pnLGy7YSEBZXZ1XTFbLcTZE8oTy3oDW9WJf9KKZwMvVcePIfoTSvzXHRcGxkQQ==} + '@anthropic-ai/sdk@0.68.0': + resolution: {integrity: sha512-SMYAmbbiprG8k1EjEPMTwaTqssDT7Ae+jxcR5kWXiqTlbwMR2AthXtscEVWOHkRfyAV5+y3PFYTJRNa3OJWIEw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@anthropic-ai/sdk@0.9.1': resolution: {integrity: sha512-wa1meQ2WSfoY8Uor3EdrJq0jTiZJoKoSii2ZVWRY1oN4Tlr5s59pADg9T79FTbPe1/se5c3pBeZgJL63wmuoBA==} @@ -2202,6 +2261,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.4': + resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} + engines: {node: '>=6.9.0'} + '@babel/template@7.25.7': resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} engines: {node: '>=6.9.0'} @@ -4600,6 +4663,10 @@ packages: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -4611,6 +4678,11 @@ packages: charenc@0.0.2: resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==} + check-node-version@4.2.1: + resolution: {integrity: sha512-YYmFYHV/X7kSJhuN/QYHUu998n/TRuDe8UenM3+m5NrkiH670lb9ILqHIvBencvJc4SDh+XcbXMR4b+TtubJiw==} + engines: {node: '>=8.3.0'} + hasBin: true + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -5924,6 +5996,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -6325,6 +6401,9 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + map-values@1.0.1: + resolution: {integrity: sha512-BbShUnr5OartXJe1GeccAWtfro11hhgNJg6G9/UtWKjVGvV5U4C09cg5nk8JUevhXODaXY+hQ3xxMUKSs62ONQ==} + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -6558,6 +6637,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + object-filter@1.0.2: + resolution: {integrity: sha512-NahvP2vZcy1ZiiYah30CEPw0FpDcSkSePJBMpzl5EQgCmISijiGuJm3SPYp7U+Lf2TljyaIw3E5EgkEx/TNEVA==} + object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -6923,6 +7005,9 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -7036,6 +7121,9 @@ packages: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -7074,11 +7162,6 @@ packages: engines: {node: '>=10'} hasBin: true - semver@7.6.3: - resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} - engines: {node: '>=10'} - hasBin: true - semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -7408,6 +7491,9 @@ packages: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -7845,6 +7931,12 @@ snapshots: transitivePeerDependencies: - encoding + '@anthropic-ai/sdk@0.68.0(zod@3.25.67)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 3.25.67 + '@anthropic-ai/sdk@0.9.1(encoding@0.1.13)': dependencies: '@types/node': 18.19.112 @@ -8042,6 +8134,8 @@ snapshots: '@babel/core': 7.25.7 '@babel/helper-plugin-utils': 7.25.7 + '@babel/runtime@7.28.4': {} + '@babel/template@7.25.7': dependencies: '@babel/code-frame': 7.25.7 @@ -10100,7 +10194,7 @@ snapshots: '@opentelemetry/propagator-b3': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/propagator-jaeger': 1.25.1(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.25.1(@opentelemetry/api@1.9.0) - semver: 7.6.3 + semver: 7.7.2 '@opentelemetry/semantic-conventions@1.25.1': {} @@ -10861,6 +10955,11 @@ snapshots: escape-string-regexp: 1.0.5 supports-color: 5.5.0 + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -10870,6 +10969,15 @@ snapshots: charenc@0.0.2: {} + check-node-version@4.2.1: + dependencies: + chalk: 3.0.0 + map-values: 1.0.1 + minimist: 1.2.8 + object-filter: 1.0.2 + run-parallel: 1.2.0 + semver: 6.3.1 + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -12740,6 +12848,11 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.28.4 + ts-algebra: 2.0.0 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -13082,6 +13195,8 @@ snapshots: dependencies: tmpl: 1.0.5 + map-values@1.0.1: {} + markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -13293,6 +13408,8 @@ snapshots: object-assign@4.1.1: {} + object-filter@1.0.2: {} + object-hash@3.0.0: {} object-inspect@1.13.1: {} @@ -13654,6 +13771,8 @@ snapshots: dependencies: side-channel: 1.1.0 + queue-microtask@1.2.3: {} + range-parser@1.2.1: {} raw-body@2.5.2: @@ -13817,6 +13936,10 @@ snapshots: transitivePeerDependencies: - supports-color + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -13854,8 +13977,6 @@ snapshots: dependencies: lru-cache: 6.0.0 - semver@7.6.3: {} - semver@7.7.2: {} send@0.19.0: @@ -14261,6 +14382,8 @@ snapshots: triple-beam@1.4.1: {} + ts-algebra@2.0.0: {} + ts-interface-checker@0.1.13: {} ts-jest@29.4.0(@babel/core@7.25.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.1)(ts-node@10.9.2(@types/node@20.19.1)(typescript@4.9.5)))(typescript@4.9.5):