diff --git a/core/package.json b/core/package.json index 98d932e7..3e689c6e 100644 --- a/core/package.json +++ b/core/package.json @@ -41,20 +41,21 @@ }, "dependencies": { "@a2a-js/sdk": "^0.3.10", - "winston": "^3.19.0", + "@anthropic-ai/sdk": "^0.79.0", "@google/genai": "^1.37.0", "@mikro-orm/core": "^6.6.6", "@mikro-orm/reflection": "^6.6.6", "@modelcontextprotocol/sdk": "^1.26.0", "google-auth-library": "^10.3.0", "lodash-es": "^4.17.23", + "winston": "^3.19.0", "zod": "^4.2.1", "zod-to-json-schema": "^3.25.1" }, "devDependencies": { + "@mikro-orm/sqlite": "^6.6.6", "@types/lodash-es": "^4.17.12", - "openapi-types": "^12.1.3", - "@mikro-orm/sqlite": "^6.6.6" + "openapi-types": "^12.1.3" }, "peerDependencies": { "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", diff --git a/core/src/common.ts b/core/src/common.ts index f9d25db1..43d70dce 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -127,6 +127,8 @@ export type { } from './memory/base_memory_service.js'; export {InMemoryMemoryService} from './memory/in_memory_memory_service.js'; export type {MemoryEntry} from './memory/memory_entry.js'; +export {AnthropicLlm} from './models/anthropic_llm.js'; +export type {AnthropicLlmParams} from './models/anthropic_llm.js'; export {ApigeeLlm} from './models/apigee_llm.js'; export type {ApigeeLlmParams} from './models/apigee_llm.js'; export {BaseLlm, isBaseLlm} from './models/base_llm.js'; diff --git a/core/src/models/anthropic_llm.ts b/core/src/models/anthropic_llm.ts new file mode 100644 index 00000000..a483eeba --- /dev/null +++ b/core/src/models/anthropic_llm.ts @@ -0,0 +1,570 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type Anthropic from '@anthropic-ai/sdk'; +import type { + ContentBlockParam, + MessageCreateParamsNonStreaming, + MessageParam, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + TextBlockParam, + Tool, + ToolResultBlockParam, + ToolUseBlockParam, +} from '@anthropic-ai/sdk/resources/messages/messages'; +import type { + Content, + FunctionDeclaration, + GenerateContentConfig, + Part, + Schema, +} from '@google/genai'; + +import {isBrowser, randomUUID} from '../utils/env_aware_utils.js'; +import {logger} from '../utils/logger.js'; + +import {BaseLlm} from './base_llm.js'; +import {BaseLlmConnection} from './base_llm_connection.js'; +import {LlmRequest} from './llm_request.js'; +import {LlmResponse} from './llm_response.js'; + +/** + * The parameters for creating an AnthropicLlm instance. + */ +export interface AnthropicLlmParams { + /** + * The model name. Defaults to 'claude-sonnet-4-5-20250929'. + */ + model?: string; + /** + * The API key. If not provided, it will look for the ANTHROPIC_API_KEY + * environment variable. + */ + apiKey?: string; + /** + * The maximum number of tokens to generate. Defaults to 8192. + */ + maxTokens?: number; + /** + * Custom base URL for the Anthropic API. + */ + baseURL?: string; +} + +/** + * Integration for Anthropic Claude models. + */ +export class AnthropicLlm extends BaseLlm { + /** + * A list of model name patterns that are supported by this LLM. + */ + static override readonly supportedModels: Array = [ + /claude-.*/, + ]; + + private readonly apiKey?: string; + private readonly maxTokens: number; + private readonly baseURL?: string; + private _client?: Anthropic; + + constructor({model, apiKey, maxTokens, baseURL}: AnthropicLlmParams) { + if (!model) { + model = 'claude-sonnet-4-5-20250929'; + } + super({model}); + + if (!apiKey && !isBrowser()) { + apiKey = process.env['ANTHROPIC_API_KEY']; + } + if (!apiKey) { + throw new Error( + 'Anthropic API key must be provided via constructor or ANTHROPIC_API_KEY environment variable.', + ); + } + + this.apiKey = apiKey; + this.maxTokens = maxTokens ?? 8192; + this.baseURL = baseURL; + } + + private async getClient(): Promise { + if (!this._client) { + const {default: AnthropicSDK} = await import('@anthropic-ai/sdk'); + this._client = new AnthropicSDK({ + apiKey: this.apiKey, + ...(this.baseURL ? {baseURL: this.baseURL} : {}), + }) as Anthropic; + } + return this._client; + } + + /** + * Generates content from the Anthropic model. + */ + override async *generateContentAsync( + llmRequest: LlmRequest, + stream = false, + ): AsyncGenerator { + this.maybeAppendUserContent(llmRequest); + + const {messages, system} = this.convertRequest(llmRequest); + const tools = this.convertTools(llmRequest.config); + + logger.info( + `Sending out request, model: ${llmRequest.model ?? this.model}, stream: ${stream}`, + ); + + const createParams: MessageCreateParamsNonStreaming = { + model: llmRequest.model ?? this.model, + max_tokens: this.maxTokens, + messages, + ...(system ? {system} : {}), + ...(tools.length + ? { + tools, + tool_choice: { + type: 'auto' as const, + disable_parallel_tool_use: true, + }, + } + : {}), + ...(llmRequest.config?.temperature != null + ? {temperature: llmRequest.config.temperature} + : {}), + ...(llmRequest.config?.topP != null + ? {top_p: llmRequest.config.topP} + : {}), + ...(llmRequest.config?.topK != null + ? {top_k: llmRequest.config.topK} + : {}), + ...(llmRequest.config?.stopSequences?.length + ? {stop_sequences: llmRequest.config.stopSequences} + : {}), + }; + + if (stream) { + yield* this.generateStreaming(createParams); + } else { + yield* this.generateNonStreaming(createParams); + } + } + + /** + * Live connection is not supported for Claude models. + */ + override async connect(_llmRequest: LlmRequest): Promise { + throw new Error('Live connection is not supported for Claude models.'); + } + + // --------------------------------------------------------------------------- + // Non-streaming + // --------------------------------------------------------------------------- + + private async *generateNonStreaming( + params: MessageCreateParamsNonStreaming, + ): AsyncGenerator { + const client = await this.getClient(); + const message = await client.messages.create(params); + yield this.convertResponse(message); + } + + // --------------------------------------------------------------------------- + // Streaming + // --------------------------------------------------------------------------- + + private async *generateStreaming( + params: MessageCreateParamsNonStreaming, + ): AsyncGenerator { + const client = await this.getClient(); + const stream = client.messages.stream({...params}); + + // Track content blocks being built during streaming + const contentBlocks: Map< + number, + {type: string; id?: string; name?: string; text: string; input: string} + > = new Map(); + + for await (const event of stream) { + if (event.type === 'content_block_start') { + const startEvent = event as RawContentBlockStartEvent; + const block = startEvent.content_block; + if (block.type === 'text') { + contentBlocks.set(startEvent.index, { + type: 'text', + text: '', + input: '', + }); + } else if (block.type === 'tool_use') { + contentBlocks.set(startEvent.index, { + type: 'tool_use', + id: block.id, + name: block.name, + text: '', + input: '', + }); + } + } else if (event.type === 'content_block_delta') { + const deltaEvent = event as RawContentBlockDeltaEvent; + const tracked = contentBlocks.get(deltaEvent.index); + if (!tracked) continue; + + if (deltaEvent.delta.type === 'text_delta') { + tracked.text += deltaEvent.delta.text; + // Yield partial text + yield { + content: { + role: 'model', + parts: [{text: deltaEvent.delta.text}], + }, + partial: true, + }; + } else if (deltaEvent.delta.type === 'input_json_delta') { + tracked.input += + (deltaEvent.delta as {partial_json?: string}).partial_json ?? ''; + } + } else if (event.type === 'content_block_stop') { + // Block complete — nothing to emit here; final message handles it + } + } + + // Yield the final complete message + const finalMessage = await stream.finalMessage(); + yield this.convertResponse(finalMessage); + } + + // --------------------------------------------------------------------------- + // Request conversion: LlmRequest → Anthropic format + // --------------------------------------------------------------------------- + + private convertRequest(llmRequest: LlmRequest): { + messages: MessageParam[]; + system: string | undefined; + } { + // Extract system instruction + const system = this.extractSystemInstruction(llmRequest.config); + + // Convert contents to Anthropic messages + const rawMessages: MessageParam[] = []; + for (const content of llmRequest.contents) { + const msg = this.contentToMessageParam(content); + if (msg) { + rawMessages.push(msg); + } + } + + // Anthropic requires alternating user/assistant messages + const messages = mergeConsecutiveSameRoleMessages(rawMessages); + + // Anthropic requires the first message to be from user + if (messages.length > 0 && messages[0].role !== 'user') { + messages.unshift({ + role: 'user', + content: [{type: 'text', text: 'Continue.'}], + }); + } + + return {messages, system}; + } + + private extractSystemInstruction( + config?: GenerateContentConfig, + ): string | undefined { + if (!config?.systemInstruction) { + return undefined; + } + + const si = config.systemInstruction; + if (typeof si === 'string') { + return si; + } + + // ContentUnion can be Content | Part | PartUnion[] + // Check if it's a Content object with parts + if ( + typeof si === 'object' && + si !== null && + 'parts' in si && + Array.isArray((si as Content).parts) + ) { + return (si as Content) + .parts!.map((part: Part) => part.text ?? '') + .filter((t: string) => t.length > 0) + .join('\n'); + } + + // Single Part with text + if (typeof si === 'object' && si !== null && 'text' in si) { + return (si as Part).text ?? undefined; + } + + // Array of PartUnion + if (Array.isArray(si)) { + return si + .map((part) => { + if (typeof part === 'string') return part; + if (typeof part === 'object' && part !== null && 'text' in part) { + return (part as Part).text ?? ''; + } + return ''; + }) + .filter((t: string) => t.length > 0) + .join('\n'); + } + + return undefined; + } + + private contentToMessageParam(content: Content): MessageParam | undefined { + if (!content.parts?.length) { + return undefined; + } + + const role = toAnthropicRole(content.role ?? 'user'); + const blocks: ContentBlockParam[] = []; + + for (const part of content.parts) { + if (part.functionCall) { + blocks.push({ + type: 'tool_use', + id: part.functionCall.id ?? `fc_${randomUUID()}`, + name: part.functionCall.name ?? '', + input: part.functionCall.args ?? {}, + } as ToolUseBlockParam); + } else if (part.functionResponse) { + const responseContent = part.functionResponse.response + ? extractResultString(part.functionResponse.response) + : ''; + blocks.push({ + type: 'tool_result', + tool_use_id: part.functionResponse.id ?? '', + content: responseContent, + is_error: false, + } as ToolResultBlockParam); + } else if (part.text != null) { + // Skip thought parts — Anthropic doesn't accept them in input + if ('thought' in part && part.thought) { + continue; + } + if (part.text.length > 0) { + blocks.push({type: 'text', text: part.text} as TextBlockParam); + } + } + // Other part types (inlineData, fileData, etc.) are not yet supported + } + + if (blocks.length === 0) { + return undefined; + } + + return {role, content: blocks}; + } + + // --------------------------------------------------------------------------- + // Tool conversion: FunctionDeclaration → Anthropic Tool + // --------------------------------------------------------------------------- + + private convertTools(config?: GenerateContentConfig): Tool[] { + if (!config?.tools?.length) { + return []; + } + + const anthropicTools: Tool[] = []; + for (const toolGroup of config.tools) { + // ToolUnion = Tool | CallableTool. Only Tool has functionDeclarations. + const fds = (toolGroup as {functionDeclarations?: FunctionDeclaration[]}) + .functionDeclarations; + if (!fds?.length) { + continue; + } + for (const fd of fds) { + anthropicTools.push(this.functionDeclarationToTool(fd)); + } + } + return anthropicTools; + } + + private functionDeclarationToTool(fd: FunctionDeclaration): Tool { + const inputSchema: Tool.InputSchema = { + type: 'object' as const, + properties: fd.parameters + ? (normalizeSchemaTypes(schemaToJsonSchema(fd.parameters as Schema)) + .properties ?? {}) + : {}, + }; + + return { + name: fd.name ?? '', + description: fd.description ?? '', + input_schema: inputSchema, + }; + } + + // --------------------------------------------------------------------------- + // Response conversion: Anthropic Message → LlmResponse + // --------------------------------------------------------------------------- + + private convertResponse(message: Anthropic.Messages.Message): LlmResponse { + const parts: Part[] = []; + + for (const block of message.content) { + if (block.type === 'text') { + parts.push({text: block.text}); + } else if (block.type === 'tool_use') { + parts.push({ + functionCall: { + id: block.id, + name: block.name, + args: block.input as Record, + }, + }); + } + // Other block types (thinking, etc.) are ignored for now + } + + return { + content: { + role: 'model', + parts, + }, + usageMetadata: { + promptTokenCount: message.usage.input_tokens, + candidatesTokenCount: message.usage.output_tokens, + totalTokenCount: + message.usage.input_tokens + message.usage.output_tokens, + }, + turnComplete: true, + }; + } +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +/** + * Maps Google GenAI roles to Anthropic roles. + */ +function toAnthropicRole(role: string): 'user' | 'assistant' { + if (role === 'model' || role === 'assistant') { + return 'assistant'; + } + return 'user'; +} + +/** + * Merges consecutive messages with the same role. + * Anthropic API requires alternating user/assistant messages. + */ +function mergeConsecutiveSameRoleMessages( + messages: MessageParam[], +): MessageParam[] { + if (messages.length === 0) return []; + + const merged: MessageParam[] = []; + for (const msg of messages) { + const last = merged[merged.length - 1]; + if (last && last.role === msg.role) { + // Merge content arrays + const lastContent = Array.isArray(last.content) + ? last.content + : [{type: 'text' as const, text: last.content}]; + const msgContent = Array.isArray(msg.content) + ? msg.content + : [{type: 'text' as const, text: msg.content}]; + last.content = [...lastContent, ...msgContent]; + } else { + merged.push({ + role: msg.role, + content: Array.isArray(msg.content) ? [...msg.content] : msg.content, + }); + } + } + return merged; +} + +/** + * Extracts a result string from a function response object. + */ +function extractResultString(response: Record): string { + if ('result' in response) { + const result = response['result']; + return typeof result === 'string' ? result : JSON.stringify(result); + } + return JSON.stringify(response); +} + +/** + * Converts a Google GenAI Schema to a plain JSON Schema object. + */ +function schemaToJsonSchema(schema: Schema): Record { + const result: Record = {}; + + if (schema.type) { + result.type = schema.type; + } + if (schema.description) { + result.description = schema.description; + } + if (schema.enum) { + result.enum = schema.enum; + } + if (schema.format) { + result.format = schema.format; + } + if (schema.items) { + result.items = schemaToJsonSchema(schema.items as Schema); + } + if (schema.properties) { + const props: Record = {}; + for (const [key, value] of Object.entries(schema.properties)) { + props[key] = schemaToJsonSchema(value as Schema); + } + result.properties = props; + } + if (schema.required) { + result.required = schema.required; + } + + return result; +} + +/** + * Recursively normalizes type strings to lowercase. + * Google GenAI uses uppercase types ("STRING", "OBJECT") but + * Anthropic expects lowercase ("string", "object"). + */ +function normalizeSchemaTypes( + schema: Record, +): Record { + const result = {...schema}; + + if (typeof result.type === 'string') { + result.type = result.type.toLowerCase(); + } + + if (result.properties && typeof result.properties === 'object') { + const props: Record = {}; + for (const [key, value] of Object.entries( + result.properties as Record, + )) { + if (value && typeof value === 'object') { + props[key] = normalizeSchemaTypes(value as Record); + } else { + props[key] = value; + } + } + result.properties = props; + } + + if (result.items && typeof result.items === 'object') { + result.items = normalizeSchemaTypes( + result.items as Record, + ); + } + + return result; +} diff --git a/core/src/models/registry.ts b/core/src/models/registry.ts index 18a1333d..2aea6388 100644 --- a/core/src/models/registry.ts +++ b/core/src/models/registry.ts @@ -6,6 +6,7 @@ import {logger} from '../utils/logger.js'; +import {AnthropicLlm} from './anthropic_llm.js'; import {ApigeeLlm} from './apigee_llm.js'; import {BaseLlm} from './base_llm.js'; import {Gemini} from './google_llm.js'; @@ -131,3 +132,4 @@ export class LLMRegistry { /** Registers default LLM factories, e.g. for Gemini models. */ LLMRegistry.register(Gemini); LLMRegistry.register(ApigeeLlm); +LLMRegistry.register(AnthropicLlm); diff --git a/core/test/models/anthropic_llm_test.ts b/core/test/models/anthropic_llm_test.ts new file mode 100644 index 00000000..03c6c1e7 --- /dev/null +++ b/core/test/models/anthropic_llm_test.ts @@ -0,0 +1,512 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type {LlmRequest} from '@google/adk'; +import {AnthropicLlm, isBaseLlm, LlmAgent, LLMRegistry} from '@google/adk'; +import type {Content} from '@google/genai'; + +// --------------------------------------------------------------------------- +// Helper: create a minimal LlmRequest +// --------------------------------------------------------------------------- +function makeLlmRequest(overrides: Partial = {}): LlmRequest { + return { + contents: [], + liveConnectConfig: {}, + toolsDict: {}, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Constructor & basics +// --------------------------------------------------------------------------- +describe('AnthropicLlm', () => { + const originalEnv = process.env['ANTHROPIC_API_KEY']; + + afterEach(() => { + if (originalEnv) { + process.env['ANTHROPIC_API_KEY'] = originalEnv; + } else { + delete process.env['ANTHROPIC_API_KEY']; + } + }); + + it('should create instance with explicit API key', () => { + const llm = new AnthropicLlm({ + model: 'claude-sonnet-4-5-20250929', + apiKey: 'test-key', + }); + expect(llm.model).toBe('claude-sonnet-4-5-20250929'); + expect(isBaseLlm(llm)).toBe(true); + }); + + it('should use default model name when not provided', () => { + const llm = new AnthropicLlm({apiKey: 'test-key'}); + expect(llm.model).toBe('claude-sonnet-4-5-20250929'); + }); + + it('should read API key from environment variable', () => { + process.env['ANTHROPIC_API_KEY'] = 'env-key'; + const llm = new AnthropicLlm({model: 'claude-sonnet-4-5-20250929'}); + expect(llm.model).toBe('claude-sonnet-4-5-20250929'); + }); + + it('should throw when no API key is provided', () => { + delete process.env['ANTHROPIC_API_KEY']; + expect( + () => new AnthropicLlm({model: 'claude-sonnet-4-5-20250929'}), + ).toThrow(/API key/); + }); + + it('should throw on connect (live not supported)', async () => { + const llm = new AnthropicLlm({apiKey: 'key'}); + await expect(llm.connect(makeLlmRequest())).rejects.toThrow( + /not supported/, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Model pattern matching +// --------------------------------------------------------------------------- +describe('AnthropicLlm.supportedModels', () => { + it('should match claude model patterns', () => { + const patterns = AnthropicLlm.supportedModels; + const testCases = [ + 'claude-sonnet-4-5-20250929', + 'claude-opus-4-0-20250514', + 'claude-haiku-4-5-20251001', + 'claude-3-5-sonnet-20241022', + 'claude-3-opus-20240229', + ]; + + for (const modelName of testCases) { + const matched = patterns.some((pattern) => { + const regex = + pattern instanceof RegExp + ? new RegExp(`^${pattern.source}$`, pattern.flags) + : new RegExp(`^${pattern}$`); + return regex.test(modelName); + }); + expect(matched).toBe(true); + } + }); + + it('should not match non-claude patterns', () => { + const patterns = AnthropicLlm.supportedModels; + const nonClaude = ['gemini-2.5-flash', 'gpt-4o', 'llama-3']; + + for (const modelName of nonClaude) { + const matched = patterns.some((pattern) => { + const regex = + pattern instanceof RegExp + ? new RegExp(`^${pattern.source}$`, pattern.flags) + : new RegExp(`^${pattern}$`); + return regex.test(modelName); + }); + expect(matched).toBe(false); + } + }); +}); + +// --------------------------------------------------------------------------- +// Registry integration +// --------------------------------------------------------------------------- +describe('LLMRegistry with AnthropicLlm', () => { + it('should resolve claude model names via registry', () => { + process.env['ANTHROPIC_API_KEY'] = 'test-key'; + const llm = LLMRegistry.newLlm('claude-sonnet-4-5-20250929'); + expect(llm).toBeInstanceOf(AnthropicLlm); + expect(llm.model).toBe('claude-sonnet-4-5-20250929'); + }); + + it('should work with LlmAgent using string model', () => { + process.env['ANTHROPIC_API_KEY'] = 'test-key'; + const agent = new LlmAgent({ + name: 'test_claude_agent', + model: 'claude-sonnet-4-5-20250929', + }); + expect(agent.canonicalModel).toBeInstanceOf(AnthropicLlm); + }); + + it('should work with LlmAgent using instance model', () => { + const claude = new AnthropicLlm({ + model: 'claude-opus-4-0-20250514', + apiKey: 'test-key', + }); + const agent = new LlmAgent({name: 'test_agent', model: claude}); + expect(agent.canonicalModel).toBeInstanceOf(AnthropicLlm); + expect(agent.canonicalModel.model).toBe('claude-opus-4-0-20250514'); + }); +}); + +// --------------------------------------------------------------------------- +// Content → MessageParam conversion (via generateContentAsync mocking) +// --------------------------------------------------------------------------- +describe('AnthropicLlm content conversion', () => { + let llm: AnthropicLlm; + + beforeEach(() => { + llm = new AnthropicLlm({ + apiKey: 'test-key', + model: 'claude-sonnet-4-5-20250929', + }); + }); + + // Access private methods via casting for unit testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function callConvertRequest(llmInst: any, llmRequest: LlmRequest) { + return llmInst.convertRequest(llmRequest); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function callConvertTools(llmInst: any, config: any) { + return llmInst.convertTools(config); + } + + it('should convert text contents correctly', () => { + const contents: Content[] = [ + {role: 'user', parts: [{text: 'Hello'}]}, + {role: 'model', parts: [{text: 'Hi there'}]}, + {role: 'user', parts: [{text: 'How are you?'}]}, + ]; + + const {messages} = callConvertRequest(llm, makeLlmRequest({contents})); + + expect(messages).toHaveLength(3); + expect(messages[0].role).toBe('user'); + expect(messages[1].role).toBe('assistant'); + expect(messages[2].role).toBe('user'); + }); + + it('should convert functionCall parts to tool_use blocks', () => { + const contents: Content[] = [ + {role: 'user', parts: [{text: 'What is the weather?'}]}, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_123', + name: 'get_weather', + args: {location: 'Seoul'}, + }, + }, + ], + }, + ]; + + const {messages} = callConvertRequest(llm, makeLlmRequest({contents})); + + expect(messages).toHaveLength(2); + const assistantMsg = messages[1]; + expect(assistantMsg.role).toBe('assistant'); + + const blocks = assistantMsg.content as Array<{ + type: string; + id?: string; + name?: string; + }>; + expect(blocks[0].type).toBe('tool_use'); + expect(blocks[0].id).toBe('call_123'); + expect(blocks[0].name).toBe('get_weather'); + }); + + it('should convert functionResponse parts to tool_result blocks', () => { + const contents: Content[] = [ + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_123', + name: 'get_weather', + response: {result: 'Sunny, 25°C'}, + }, + }, + ], + }, + ]; + + const {messages} = callConvertRequest(llm, makeLlmRequest({contents})); + + expect(messages).toHaveLength(1); + const blocks = messages[0].content as Array<{ + type: string; + tool_use_id?: string; + content?: string; + }>; + expect(blocks[0].type).toBe('tool_result'); + expect(blocks[0].tool_use_id).toBe('call_123'); + expect(blocks[0].content).toBe('Sunny, 25°C'); + }); + + it('should merge consecutive same-role messages', () => { + const contents: Content[] = [ + {role: 'user', parts: [{text: 'Hello'}]}, + {role: 'user', parts: [{text: 'World'}]}, + {role: 'model', parts: [{text: 'Response'}]}, + ]; + + const {messages} = callConvertRequest(llm, makeLlmRequest({contents})); + + // Two user messages should be merged into one + expect(messages).toHaveLength(2); + expect(messages[0].role).toBe('user'); + const blocks = messages[0].content as Array<{type: string; text: string}>; + expect(blocks).toHaveLength(2); + expect(blocks[0].text).toBe('Hello'); + expect(blocks[1].text).toBe('World'); + }); + + it('should prepend user message if first message is assistant', () => { + const contents: Content[] = [ + {role: 'model', parts: [{text: 'I am the assistant'}]}, + ]; + + const {messages} = callConvertRequest(llm, makeLlmRequest({contents})); + + expect(messages[0].role).toBe('user'); + expect(messages[1].role).toBe('assistant'); + }); + + it('should extract system instruction from string', () => { + const {system} = callConvertRequest( + llm, + makeLlmRequest({ + config: {systemInstruction: 'You are helpful'}, + }), + ); + + expect(system).toBe('You are helpful'); + }); + + it('should extract system instruction from Content object', () => { + const {system} = callConvertRequest( + llm, + makeLlmRequest({ + config: { + systemInstruction: { + role: 'system', + parts: [{text: 'Be concise'}, {text: 'Be accurate'}], + }, + }, + }), + ); + + expect(system).toBe('Be concise\nBe accurate'); + }); + + it('should skip thought parts', () => { + const contents: Content[] = [ + { + role: 'model', + parts: [ + {text: 'thinking...', thought: true} as Content['parts'][0], + {text: 'Actual response'}, + ], + }, + ]; + + const {messages} = callConvertRequest(llm, makeLlmRequest({contents})); + + // The thought part should be skipped, only "Actual response" remains + // But first message must be user, so a user message is prepended + expect(messages.length).toBeGreaterThanOrEqual(1); + const assistantMsg = messages.find( + (m: {role: string}) => m.role === 'assistant', + ); + expect(assistantMsg).toBeDefined(); + const blocks = assistantMsg!.content as Array<{type: string; text: string}>; + expect(blocks).toHaveLength(1); + expect(blocks[0].text).toBe('Actual response'); + }); + + it('should convert function declarations to Anthropic tools', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'get_weather', + description: 'Get weather for a location', + parameters: { + type: 'OBJECT', + properties: { + location: {type: 'STRING', description: 'City name'}, + }, + required: ['location'], + }, + }, + ], + }, + ], + }; + + const tools = callConvertTools(llm, config); + + expect(tools).toHaveLength(1); + expect(tools[0].name).toBe('get_weather'); + expect(tools[0].description).toBe('Get weather for a location'); + expect(tools[0].input_schema.type).toBe('object'); + + // Check that type strings are lowercased + const props = tools[0].input_schema.properties as Record< + string, + {type: string} + >; + expect(props.location.type).toBe('string'); + }); + + it('should handle nested schema type normalization', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'search', + description: 'Search', + parameters: { + type: 'OBJECT', + properties: { + tags: { + type: 'ARRAY', + items: {type: 'STRING'}, + }, + }, + }, + }, + ], + }, + ], + }; + + const tools = callConvertTools(llm, config); + const props = tools[0].input_schema.properties as Record< + string, + {type: string; items?: {type: string}} + >; + expect(props.tags.type).toBe('array'); + expect(props.tags.items?.type).toBe('string'); + }); +}); + +// --------------------------------------------------------------------------- +// Response conversion +// --------------------------------------------------------------------------- +describe('AnthropicLlm response conversion', () => { + let llm: AnthropicLlm; + + beforeEach(() => { + llm = new AnthropicLlm({apiKey: 'test-key'}); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function callConvertResponse(llmInst: any, message: any) { + return llmInst.convertResponse(message); + } + + it('should convert text response', () => { + const message = { + id: 'msg_123', + type: 'message' as const, + role: 'assistant' as const, + content: [{type: 'text' as const, text: 'Hello!', citations: null}], + model: 'claude-sonnet-4-5-20250929', + stop_reason: 'end_turn' as const, + stop_sequence: null, + usage: { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + server_tool_use: null, + service_tier: null, + cache_creation: null, + inference_geo: null, + }, + container: null, + }; + + const response = callConvertResponse(llm, message); + + expect(response.content).toBeDefined(); + expect(response.content.role).toBe('model'); + expect(response.content.parts).toHaveLength(1); + expect(response.content.parts[0].text).toBe('Hello!'); + expect(response.turnComplete).toBe(true); + }); + + it('should convert tool_use response', () => { + const message = { + id: 'msg_456', + type: 'message' as const, + role: 'assistant' as const, + content: [ + { + type: 'tool_use' as const, + id: 'toolu_abc', + name: 'get_weather', + input: {location: 'Tokyo'}, + caller: {type: 'direct' as const}, + }, + ], + model: 'claude-sonnet-4-5-20250929', + stop_reason: 'tool_use' as const, + stop_sequence: null, + usage: { + input_tokens: 20, + output_tokens: 15, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + server_tool_use: null, + service_tier: null, + cache_creation: null, + inference_geo: null, + }, + container: null, + }; + + const response = callConvertResponse(llm, message); + + expect(response.content.parts).toHaveLength(1); + const fc = response.content.parts[0].functionCall; + expect(fc).toBeDefined(); + expect(fc.id).toBe('toolu_abc'); + expect(fc.name).toBe('get_weather'); + expect(fc.args).toEqual({location: 'Tokyo'}); + }); + + it('should include usage metadata', () => { + const message = { + id: 'msg_789', + type: 'message' as const, + role: 'assistant' as const, + content: [{type: 'text' as const, text: 'Hi', citations: null}], + model: 'claude-sonnet-4-5-20250929', + stop_reason: 'end_turn' as const, + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: null, + cache_read_input_tokens: null, + server_tool_use: null, + service_tier: null, + cache_creation: null, + inference_geo: null, + }, + container: null, + }; + + const response = callConvertResponse(llm, message); + + expect(response.usageMetadata).toBeDefined(); + expect(response.usageMetadata.promptTokenCount).toBe(100); + expect(response.usageMetadata.candidatesTokenCount).toBe(50); + expect(response.usageMetadata.totalTokenCount).toBe(150); + }); +}); diff --git a/dev/samples/claude_agent.ts b/dev/samples/claude_agent.ts new file mode 100644 index 00000000..cc1b505f --- /dev/null +++ b/dev/samples/claude_agent.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import {FunctionTool, LlmAgent} from '@google/adk'; +import {z} from 'zod'; + +const getWeather = new FunctionTool({ + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: z.object({ + city: z.string().describe('The city name'), + }), + execute: async ({city}: {city: string}) => { + return {result: `The weather in ${city} is sunny, 22°C.`}; + }, +}); + +export const rootAgent = new LlmAgent({ + name: 'claude_weather_agent', + model: 'claude-sonnet-4-5-20250929', + description: 'A weather assistant powered by Claude.', + instruction: + 'You are a helpful weather assistant. Use the get_weather tool to answer weather questions. Be concise.', + tools: [getWeather], +}); diff --git a/package-lock.json b/package-lock.json index 58b31d06..6a8f328a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "license": "Apache-2.0", "dependencies": { "@a2a-js/sdk": "^0.3.10", + "@anthropic-ai/sdk": "^0.79.0", "@google/genai": "^1.37.0", "@mikro-orm/core": "^6.6.6", "@mikro-orm/reflection": "^6.6.6", @@ -373,6 +374,26 @@ "node": ">=6.0.0" } }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.79.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.79.0.tgz", + "integrity": "sha512-ietmtM6glcnnrWq26H+BZm8J07iay9Cob6hRzDTr/A9QWF1m2T//TQhFO4MTKcZht2/7LS8bG9wUYEhcizKRnA==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@azu/format-text": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@azu/format-text/-/format-text-1.0.2.tgz", @@ -708,6 +729,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -9543,6 +9573,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -13852,6 +13895,12 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", diff --git a/tests/e2e/models/anthropic_llm_e2e_test.ts b/tests/e2e/models/anthropic_llm_e2e_test.ts new file mode 100644 index 00000000..d18cd83c --- /dev/null +++ b/tests/e2e/models/anthropic_llm_e2e_test.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AnthropicLlm, + FunctionTool, + InMemoryRunner, + LlmAgent, + StreamingMode, +} from '@google/adk'; +import {createUserContent} from '@google/genai'; +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; +import * as path from 'path'; +import {describe, expect, it} from 'vitest'; +import {z} from 'zod'; + +describe('E2E AnthropicLlm', () => { + // Load .env from e2e dir or project root + for (const rel of [ + path.resolve(__dirname, '..', '.env'), + path.resolve(__dirname, '..', '..', '..', '.env'), + ]) { + if (fs.existsSync(rel)) { + dotenv.config({path: rel}); + } + } + + const hasKey = !!process.env.ANTHROPIC_API_KEY; + + // ------------------------------------------------------------------------- + // Basic text generation + // ------------------------------------------------------------------------- + it.skipIf(!hasKey)( + 'should generate a text response with Claude', + async () => { + const agent = new LlmAgent({ + name: 'claude_text_agent', + model: 'claude-sonnet-4-5-20250929', + instruction: 'You are a helpful assistant. Keep responses very short.', + }); + + const runner = new InMemoryRunner({ + agent, + appName: 'e2e_anthropic_test', + }); + const session = await runner.sessionService.createSession({ + appName: 'e2e_anthropic_test', + userId: 'test_user', + }); + + let finalResponse = ''; + for await (const event of runner.runAsync({ + userId: 'test_user', + sessionId: session.id, + newMessage: createUserContent( + 'What is 2 + 3? Reply with just the number.', + ), + })) { + if ( + event.author === 'claude_text_agent' && + event.content?.parts?.[0]?.text + ) { + finalResponse += event.content.parts[0].text; + } + } + + expect(finalResponse).toContain('5'); + }, + 30000, + ); + + // ------------------------------------------------------------------------- + // Function calling (tool use) + // ------------------------------------------------------------------------- + it.skipIf(!hasKey)( + 'should call a function tool and return the result', + async () => { + const getWeather = new FunctionTool({ + name: 'get_weather', + description: 'Get the current weather for a city.', + parameters: z.object({ + city: z.string().describe('The city name'), + }), + execute: async ({city}) => { + return {result: `The weather in ${city} is sunny, 22°C.`}; + }, + }); + + const agent = new LlmAgent({ + name: 'claude_tool_agent', + model: 'claude-sonnet-4-5-20250929', + instruction: + 'You are a weather assistant. Use the get_weather tool to answer weather questions. Keep responses short.', + tools: [getWeather], + }); + + const runner = new InMemoryRunner({ + agent, + appName: 'e2e_anthropic_tool_test', + }); + const session = await runner.sessionService.createSession({ + appName: 'e2e_anthropic_tool_test', + userId: 'test_user', + }); + + let finalResponse = ''; + for await (const event of runner.runAsync({ + userId: 'test_user', + sessionId: session.id, + newMessage: createUserContent("What's the weather in Seoul?"), + })) { + if ( + event.author === 'claude_tool_agent' && + event.content?.parts?.[0]?.text + ) { + finalResponse += event.content.parts[0].text; + } + } + + expect(finalResponse.toLowerCase()).toMatch(/sunny|22/); + }, + 30000, + ); + + // ------------------------------------------------------------------------- + // Streaming + // ------------------------------------------------------------------------- + it.skipIf(!hasKey)( + 'should work with streaming mode', + async () => { + const agent = new LlmAgent({ + name: 'claude_stream_agent', + model: new AnthropicLlm({model: 'claude-sonnet-4-5-20250929'}), + instruction: 'You are a helpful assistant. Keep responses very short.', + }); + + const runner = new InMemoryRunner({ + agent, + appName: 'e2e_anthropic_stream_test', + }); + const session = await runner.sessionService.createSession({ + appName: 'e2e_anthropic_stream_test', + userId: 'test_user', + }); + + let finalResponse = ''; + for await (const event of runner.runAsync({ + userId: 'test_user', + sessionId: session.id, + newMessage: createUserContent('Say hello in Korean.'), + runConfig: {streamingMode: StreamingMode.SSE}, + })) { + if ( + event.author === 'claude_stream_agent' && + event.content?.parts?.[0]?.text + ) { + finalResponse += event.content.parts[0].text; + } + } + + expect(finalResponse).toBeTruthy(); + expect(finalResponse.length).toBeGreaterThan(0); + }, + 30000, + ); +}); diff --git a/tests/e2e/models/screenshot/screen-shot-adk-js-anthropic.png b/tests/e2e/models/screenshot/screen-shot-adk-js-anthropic.png new file mode 100644 index 00000000..e75b0283 Binary files /dev/null and b/tests/e2e/models/screenshot/screen-shot-adk-js-anthropic.png differ