diff --git a/experiments/endpoint_configs/config_template.yaml b/experiments/endpoint_configs/config_template.yaml index c7169486..c762cfe9 100644 --- a/experiments/endpoint_configs/config_template.yaml +++ b/experiments/endpoint_configs/config_template.yaml @@ -6,10 +6,21 @@ model_config_4o_openai: &client_4o_openai model: gpt-4o-2024-08-06 max_retries: 5 +# Anthropic Claude configuration example +model_config_claude_anthropic: &client_claude_anthropic + provider: autogen_ext.models.anthropic.AnthropicChatCompletionClient + config: + model: claude-4-sonnet-20251114 + # api_key: # Uncomment and add your API key + max_retries: 5 + orchestrator_client: *client_4o_openai coder_client: *client_4o_openai web_surfer_client: *client_4o_openai file_surfer_client: *client_4o_openai action_guard_client: *client_4o_openai user_proxy_client: *client_4o_openai -model_client: *client_4o_openai \ No newline at end of file +model_client: *client_4o_openai + +# To use Anthropic, uncomment and replace one or more of the above with: +# orchestrator_client: *client_claude_anthropic \ No newline at end of file diff --git a/frontend/src/components/settings/tabs/agentSettings/modelSelector/ModelSelector.tsx b/frontend/src/components/settings/tabs/agentSettings/modelSelector/ModelSelector.tsx index b446e57a..7e3459b0 100644 --- a/frontend/src/components/settings/tabs/agentSettings/modelSelector/ModelSelector.tsx +++ b/frontend/src/components/settings/tabs/agentSettings/modelSelector/ModelSelector.tsx @@ -4,6 +4,7 @@ import { OpenAIModelConfigForm, AzureModelConfigForm, OllamaModelConfigForm, + AnthropicModelConfigForm, } from "./modelConfigForms"; import { ModelConfig, ModelConfigFormProps } from "./modelConfigForms/types"; @@ -11,6 +12,7 @@ import { ModelConfig, ModelConfigFormProps } from "./modelConfigForms/types"; import { DEFAULT_OPENAI } from "./modelConfigForms/OpenAIModelConfigForm"; import { DEFAULT_AZURE } from "./modelConfigForms/AzureModelConfigForm"; import { DEFAULT_OLLAMA } from "./modelConfigForms/OllamaModelConfigForm"; +import { DEFAULT_ANTHROPIC } from "./modelConfigForms/AnthropicModelConfigForm"; interface ModelSelectorProps { onChange: (m: ModelConfig) => void; @@ -20,7 +22,8 @@ interface ModelSelectorProps { export const PROVIDERS = { openai: DEFAULT_OPENAI.provider, azure: DEFAULT_AZURE.provider, - ollama: DEFAULT_OLLAMA.provider + ollama: DEFAULT_OLLAMA.provider, + anthropic: DEFAULT_ANTHROPIC.provider } // Map each model value to its config form, label, and initial config value @@ -107,6 +110,41 @@ export const PROVIDER_FORM_MAP: Record = ({ onChange, value }) => { diff --git a/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/AnthropicModelConfigForm.tsx b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/AnthropicModelConfigForm.tsx new file mode 100644 index 00000000..1d39e52d --- /dev/null +++ b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/AnthropicModelConfigForm.tsx @@ -0,0 +1,109 @@ +import React, { useEffect } from "react"; +import { Input, Form, Button, Switch, Flex, Collapse } from "antd"; +import { ModelConfigFormProps, AnthropicModelConfig } from "./types"; + +export const DEFAULT_ANTHROPIC: AnthropicModelConfig = { + provider: "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + config: { + model: "claude-4-sonnet-20251114", + api_key: null, + base_url: null, + max_retries: 5, + } +}; + +const ADVANCED_DEFAULTS = { + vision: true, + function_calling: true, + json_output: false, + family: "claude-4-sonnet" as const, + structured_output: false, + multiple_system_messages: false, +}; + +function normalizeConfig(config: any, hideAdvancedToggles?: boolean) { + const newConfig = { ...DEFAULT_ANTHROPIC, ...config }; + if (hideAdvancedToggles) { + if (newConfig.config.model_info) delete newConfig.config.model_info; + } else { + newConfig.config.model_info = { + ...ADVANCED_DEFAULTS, + ...(newConfig.config.model_info || {}) + }; + } + return newConfig; +} + + +export const AnthropicModelConfigForm: React.FC = ({ onChange, onSubmit, value, hideAdvancedToggles }) => { + const [form] = Form.useForm(); + + const handleValuesChange = (_: any, allValues: any) => { + const mergedConfig = { ...DEFAULT_ANTHROPIC.config, ...allValues.config }; + const normalizedConfig = normalizeConfig(mergedConfig, hideAdvancedToggles); + const newValue = { ...DEFAULT_ANTHROPIC, config: normalizedConfig }; + if (onChange) onChange(newValue); + }; + const handleSubmit = () => { + const mergedConfig = { ...DEFAULT_ANTHROPIC.config, ...form.getFieldsValue().config }; + const normalizedConfig = normalizeConfig(mergedConfig, hideAdvancedToggles); + const newValue = { ...DEFAULT_ANTHROPIC, config: normalizedConfig }; + if (onSubmit) onSubmit(newValue); + }; + + + useEffect(() => { + if (value) { + form.setFieldsValue(normalizeConfig(value, hideAdvancedToggles)) + } + }, [value, form]); + + return ( +
+ + + + + + + + + + + + + + + + {!hideAdvancedToggles && ( + + + + + + + + + + + + + + + + + + )} + + + {onSubmit && } + +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/__tests__/AnthropicModelConfigForm.test.tsx b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/__tests__/AnthropicModelConfigForm.test.tsx new file mode 100644 index 00000000..dc31cb12 --- /dev/null +++ b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/__tests__/AnthropicModelConfigForm.test.tsx @@ -0,0 +1,167 @@ +/** + * @jest-environment jsdom + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { AnthropicModelConfigForm, DEFAULT_ANTHROPIC } from '../AnthropicModelConfigForm'; +import { AnthropicModelConfig } from '../types'; + +// Mock antd components +jest.mock('antd', () => ({ + Input: ({ placeholder, onChange, value, ...props }: any) => ( + onChange?.(e.target.value)} + value={value} + {...props} + /> + ), + Form: { + useForm: () => [{ setFieldsValue: jest.fn(), getFieldsValue: () => ({}) }], + Item: ({ children, label }: any) => ( +
+ + {children} +
+ ), + }, + Button: ({ children, onClick }: any) => ( + + ), + Switch: ({ checked, onChange }: any) => ( + onChange?.(e.target.checked)} + /> + ), + Flex: ({ children }: any) =>
{children}
, + Collapse: { + Panel: ({ children }: any) =>
{children}
, + }, +})); + +describe('AnthropicModelConfigForm', () => { + const mockOnChange = jest.fn(); + const mockOnSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders with default Anthropic configuration', () => { + render(); + + expect(screen.getByDisplayValue('claude-4-sonnet-20251114')).toBeInTheDocument(); + expect(screen.getByLabelText('Model')).toBeInTheDocument(); + expect(screen.getByLabelText('API Key')).toBeInTheDocument(); + expect(screen.getByLabelText('Base URL')).toBeInTheDocument(); + expect(screen.getByLabelText('Max Retries')).toBeInTheDocument(); + }); + + it('displays correct default values', () => { + expect(DEFAULT_ANTHROPIC.provider).toBe('autogen_ext.models.anthropic.AnthropicChatCompletionClient'); + expect(DEFAULT_ANTHROPIC.config.model).toBe('claude-4-sonnet-20251114'); + expect(DEFAULT_ANTHROPIC.config.max_retries).toBe(5); + }); + + it('shows advanced toggles when hideAdvancedToggles is false', () => { + render(); + + expect(screen.getByLabelText('Vision')).toBeInTheDocument(); + expect(screen.getByLabelText('Function Calling')).toBeInTheDocument(); + expect(screen.getByLabelText('JSON Output')).toBeInTheDocument(); + expect(screen.getByLabelText('Structured Output')).toBeInTheDocument(); + expect(screen.getByLabelText('Multiple System Messages')).toBeInTheDocument(); + }); + + it('hides advanced toggles when hideAdvancedToggles is true', () => { + render(); + + expect(screen.queryByLabelText('Vision')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Function Calling')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('JSON Output')).not.toBeInTheDocument(); + }); + + it('calls onChange when model value changes', async () => { + render(); + + const modelInput = screen.getByLabelText('Model'); + fireEvent.change(modelInput, { target: { value: 'claude-3-5-sonnet-20241022' } }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }); + }); + + it('calls onChange when API key changes', async () => { + render(); + + const apiKeyInput = screen.getByLabelText('API Key'); + fireEvent.change(apiKeyInput, { target: { value: 'sk-ant-test-key' } }); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalled(); + }); + }); + + it('shows Save button when onSubmit prop is provided', () => { + render(); + + expect(screen.getByText('Save')).toBeInTheDocument(); + }); + + it('does not show Save button when onSubmit prop is not provided', () => { + render(); + + expect(screen.queryByText('Save')).not.toBeInTheDocument(); + }); + + it('calls onSubmit when Save button is clicked', () => { + render(); + + const saveButton = screen.getByText('Save'); + fireEvent.click(saveButton); + + expect(mockOnSubmit).toHaveBeenCalled(); + }); + + it('renders with provided value prop', () => { + const testValue: AnthropicModelConfig = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-3-opus-20240229', + api_key: 'test-key', + base_url: 'https://custom-endpoint.com', + max_retries: 3, + } + }; + + render(); + + expect(screen.getByDisplayValue('claude-3-opus-20240229')).toBeInTheDocument(); + expect(screen.getByDisplayValue('test-key')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://custom-endpoint.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('3')).toBeInTheDocument(); + }); + + it('has correct placeholder text', () => { + render(); + + expect(screen.getByPlaceholderText('claude-4-sonnet-20251114')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Your Anthropic API key')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('https://api.anthropic.com')).toBeInTheDocument(); + }); + + it('sets correct model family in advanced defaults', () => { + const { container } = render( + + ); + + // The model family should be set to claude-4-sonnet by default + // This is tested indirectly through the form structure + expect(container).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/__tests__/types.test.ts b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/__tests__/types.test.ts new file mode 100644 index 00000000..e2599403 --- /dev/null +++ b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/__tests__/types.test.ts @@ -0,0 +1,269 @@ +import { + AnthropicModelConfigSchema, + ModelConfigSchema, + ModelFamilySchema, + type AnthropicModelConfig +} from '../types'; + +describe('Anthropic TypeScript Types', () => { + describe('AnthropicModelConfigSchema', () => { + it('validates correct Anthropic configuration', () => { + const validConfig = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114', + api_key: 'sk-ant-test-key', + base_url: 'https://api.anthropic.com', + max_retries: 5 + } + }; + + const result = AnthropicModelConfigSchema.safeParse(validConfig); + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data.provider).toBe('autogen_ext.models.anthropic.AnthropicChatCompletionClient'); + expect(result.data.config.model).toBe('claude-4-sonnet-20251114'); + } + }); + + it('validates minimal Anthropic configuration', () => { + const minimalConfig = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114' + } + }; + + const result = AnthropicModelConfigSchema.safeParse(minimalConfig); + expect(result.success).toBe(true); + }); + + it('rejects configuration with wrong provider', () => { + const wrongProvider = { + provider: 'OpenAIChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114' + } + }; + + const result = AnthropicModelConfigSchema.safeParse(wrongProvider); + expect(result.success).toBe(false); + }); + + it('rejects configuration with empty model', () => { + const emptyModel = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: '' + } + }; + + const result = AnthropicModelConfigSchema.safeParse(emptyModel); + expect(result.success).toBe(false); + }); + + it('rejects configuration without model', () => { + const noModel = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: {} + }; + + const result = AnthropicModelConfigSchema.safeParse(noModel); + expect(result.success).toBe(false); + }); + + it('allows extra config properties with passthrough', () => { + const configWithExtras = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114', + api_key: 'test-key', + timeout: 30, + custom_header: 'custom-value' + } + }; + + const result = AnthropicModelConfigSchema.safeParse(configWithExtras); + expect(result.success).toBe(true); + + if (result.success) { + expect((result.data.config as any).timeout).toBe(30); + expect((result.data.config as any).custom_header).toBe('custom-value'); + } + }); + + it('validates with model_info object', () => { + const configWithModelInfo = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114', + model_info: { + vision: true, + function_calling: true, + json_output: false, + family: 'claude-4-sonnet', + structured_output: false, + multiple_system_messages: false + } + } + }; + + const result = AnthropicModelConfigSchema.safeParse(configWithModelInfo); + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data.config.model_info?.vision).toBe(true); + expect(result.data.config.model_info?.family).toBe('claude-4-sonnet'); + } + }); + }); + + describe('ModelConfigSchema discriminated union', () => { + it('accepts Anthropic configuration in union', () => { + const anthropicConfig = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114' + } + }; + + const result = ModelConfigSchema.safeParse(anthropicConfig); + expect(result.success).toBe(true); + }); + + it('accepts OpenAI configuration in union', () => { + const openaiConfig = { + provider: 'OpenAIChatCompletionClient', + config: { + model: 'gpt-4o-2024-08-06' + } + }; + + const result = ModelConfigSchema.safeParse(openaiConfig); + expect(result.success).toBe(true); + }); + + it('accepts Azure configuration in union', () => { + const azureConfig = { + provider: 'AzureOpenAIChatCompletionClient', + config: { + model: 'gpt-4o', + azure_endpoint: 'https://test.openai.azure.com', + azure_deployment: 'gpt-4o' + } + }; + + const result = ModelConfigSchema.safeParse(azureConfig); + expect(result.success).toBe(true); + }); + + it('accepts Ollama configuration in union', () => { + const ollamaConfig = { + provider: 'autogen_ext.models.ollama.OllamaChatCompletionClient', + config: { + model: 'llama2' + } + }; + + const result = ModelConfigSchema.safeParse(ollamaConfig); + expect(result.success).toBe(true); + }); + + it('rejects unknown provider in union', () => { + const unknownConfig = { + provider: 'UnknownProvider', + config: { + model: 'some-model' + } + }; + + const result = ModelConfigSchema.safeParse(unknownConfig); + expect(result.success).toBe(false); + }); + }); + + describe('ModelFamilySchema', () => { + it('includes Claude model families', () => { + const claudeFamilies = [ + 'claude-3-haiku', + 'claude-3-sonnet', + 'claude-3-opus', + 'claude-3-5-haiku', + 'claude-3-5-sonnet', + 'claude-3-7-sonnet', + 'claude-4-opus', + 'claude-4-sonnet' + ]; + + claudeFamilies.forEach(family => { + const result = ModelFamilySchema.safeParse(family); + expect(result.success).toBe(true); + }); + }); + + it('accepts claude-4-sonnet family', () => { + const result = ModelFamilySchema.safeParse('claude-4-sonnet'); + expect(result.success).toBe(true); + }); + + it('rejects unknown model family', () => { + const result = ModelFamilySchema.safeParse('claude-5-ultra'); + expect(result.success).toBe(false); + }); + + it('includes unknown fallback', () => { + const result = ModelFamilySchema.safeParse('unknown'); + expect(result.success).toBe(true); + }); + }); + + describe('TypeScript type checking', () => { + it('has correct AnthropicModelConfig type structure', () => { + // This tests TypeScript compilation - if it compiles, the types are correct + const config: AnthropicModelConfig = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114', + api_key: 'test-key', + base_url: 'https://api.anthropic.com', + max_retries: 5, + model_info: { + vision: true, + function_calling: true, + json_output: false, + family: 'claude-4-sonnet', + structured_output: false, + multiple_system_messages: false + } + } + }; + + expect(config.provider).toBe('autogen_ext.models.anthropic.AnthropicChatCompletionClient'); + expect(config.config.model).toBe('claude-4-sonnet-20251114'); + }); + + it('enforces literal provider type', () => { + // This should cause a TypeScript error if uncommented: + // const badConfig: AnthropicModelConfig = { + // provider: 'WrongProvider', + // config: { model: 'claude-4-sonnet-20251114' } + // }; + + // Test passes if it compiles without the above + expect(true).toBe(true); + }); + + it('allows optional config properties', () => { + const minimalConfig: AnthropicModelConfig = { + provider: 'autogen_ext.models.anthropic.AnthropicChatCompletionClient', + config: { + model: 'claude-4-sonnet-20251114' + // api_key, base_url, max_retries are optional + } + }; + + expect(minimalConfig.config.model).toBe('claude-4-sonnet-20251114'); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/index.ts b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/index.ts index 4a2a40f5..0f3a3022 100644 --- a/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/index.ts +++ b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/index.ts @@ -1,3 +1,4 @@ export { OpenAIModelConfigForm } from "./OpenAIModelConfigForm"; export { OllamaModelConfigForm } from "./OllamaModelConfigForm"; export { AzureModelConfigForm } from "./AzureModelConfigForm"; +export { AnthropicModelConfigForm } from "./AnthropicModelConfigForm"; diff --git a/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/types.tsx b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/types.tsx index d4ae8e35..6b978f8b 100644 --- a/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/types.tsx +++ b/frontend/src/components/settings/tabs/agentSettings/modelSelector/modelConfigForms/types.tsx @@ -80,10 +80,21 @@ export const OllamaModelConfigSchema = z.object({ export type OllamaModelConfig = z.infer; +export const AnthropicModelConfigSchema = z.object({ + provider: z.literal("autogen_ext.models.anthropic.AnthropicChatCompletionClient"), + config: z.object({ + model: z.string().min(1, "Model name is required."), + model_info: ModelInfoSchema.optional() + }).passthrough(), +}).passthrough(); + +export type AnthropicModelConfig = z.infer; + export const ModelConfigSchema = z.discriminatedUnion("provider", [ OpenAIModelConfigSchema, AzureModelConfigSchema, - OllamaModelConfigSchema + OllamaModelConfigSchema, + AnthropicModelConfigSchema ]); export type ModelConfig = z.infer; diff --git a/pyproject.toml b/pyproject.toml index 419bd918..3ebfba98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ dependencies = [ "autogen-agentchat==0.5.7", "autogen-core==0.5.7", - "autogen-ext[openai,docker,file-surfer,web-surfer,magentic-one,task-centric-memory,mcp]==0.5.7", + "autogen-ext[openai,docker,file-surfer,web-surfer,magentic-one,task-centric-memory,mcp,anthropic]==0.5.7", "aioconsole", "nest_asyncio", "docker", diff --git a/src/magentic_ui/backend/web/routes/validation.py b/src/magentic_ui/backend/web/routes/validation.py index 75782222..d1c43567 100644 --- a/src/magentic_ui/backend/web/routes/validation.py +++ b/src/magentic_ui/backend/web/routes/validation.py @@ -40,6 +40,11 @@ def validate_provider(provider: str) -> Optional[ValidationError]: "OpenAIChatCompletionClient", ]: provider = "autogen_ext.models.openai.OpenAIChatCompletionClient" + elif provider in [ + "anthropic_chat_completion_client", + "AnthropicChatCompletionClient", + ]: + provider = "autogen_ext.models.anthropic.AnthropicChatCompletionClient" module_path, class_name = provider.rsplit(".", maxsplit=1) module = importlib.import_module(module_path) diff --git a/tests/test_anthropic_integration.py b/tests/test_anthropic_integration.py new file mode 100644 index 00000000..df5290d9 --- /dev/null +++ b/tests/test_anthropic_integration.py @@ -0,0 +1,244 @@ +import pytest +import os +from unittest.mock import patch, MagicMock, AsyncMock +from autogen_core import ComponentModel +from autogen_ext.models.anthropic import AnthropicChatCompletionClient + + +class TestAnthropicIntegration: + """Integration tests for Anthropic provider.""" + + @pytest.fixture + def anthropic_config(self): + """Basic Anthropic configuration for testing.""" + return { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114", + "api_key": "test-api-key-12345", + "max_retries": 3 + } + } + + @pytest.fixture + def component_model(self, anthropic_config): + """Create a ComponentModel from Anthropic config.""" + return ComponentModel(**anthropic_config) + + def test_anthropic_client_import(self): + """Test that AnthropicChatCompletionClient can be imported.""" + try: + from autogen_ext.models.anthropic import AnthropicChatCompletionClient + assert AnthropicChatCompletionClient is not None + except ImportError: + pytest.fail("AnthropicChatCompletionClient could not be imported") + + def test_anthropic_component_model_creation(self, anthropic_config): + """Test creating ComponentModel with Anthropic config.""" + model = ComponentModel(**anthropic_config) + assert model.provider == "autogen_ext.models.anthropic.AnthropicChatCompletionClient" + assert model.config["model"] == "claude-4-sonnet-20251114" + assert model.config["api_key"] == "test-api-key-12345" + + @pytest.mark.skipif( + "ANTHROPIC_API_KEY" not in os.environ, + reason="ANTHROPIC_API_KEY environment variable not set" + ) + def test_anthropic_client_initialization_with_real_key(self): + """Test Anthropic client initialization with real API key (if available).""" + config = ComponentModel( + provider="autogen_ext.models.anthropic.AnthropicChatCompletionClient", + config={ + "model": "claude-4-sonnet-20251114", + "api_key": os.environ["ANTHROPIC_API_KEY"] + } + ) + + try: + client = AnthropicChatCompletionClient.load_component(config) + assert client is not None + except Exception as e: + pytest.fail(f"Failed to initialize Anthropic client with real API key: {e}") + + def test_anthropic_client_load_component(self, component_model): + """Test loading Anthropic component.""" + # Just test that load_component doesn't raise an exception + # The actual loading would require a real API key + try: + AnthropicChatCompletionClient.load_component(component_model) + except Exception as e: + # Expected to fail without real API key, but should not crash + assert "api_key" in str(e).lower() or "authentication" in str(e).lower() or "unauthorized" in str(e).lower() + + def test_anthropic_config_with_base_url(self): + """Test Anthropic configuration with custom base URL.""" + config = ComponentModel( + provider="autogen_ext.models.anthropic.AnthropicChatCompletionClient", + config={ + "model": "claude-4-sonnet-20251114", + "api_key": "test-key", + "base_url": "https://custom-anthropic-endpoint.com" + } + ) + + assert config.config["base_url"] == "https://custom-anthropic-endpoint.com" + + def test_anthropic_config_validation_missing_model(self): + """Test that missing model is handled gracefully.""" + # ComponentModel itself doesn't validate config contents + # That's done by the validation service + config = ComponentModel( + provider="autogen_ext.models.anthropic.AnthropicChatCompletionClient", + config={ + "api_key": "test-key" + # Missing model field - this should be caught by validation service + } + ) + assert config.config["api_key"] == "test-key" + + @pytest.mark.parametrize("model_name", [ + "claude-4-sonnet-20251114", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + "claude-3-opus-20240229", + "gpt-4", # Should still work, just not an Anthropic model + ]) + def test_anthropic_model_names(self, model_name): + """Test various model names with Anthropic provider.""" + config = ComponentModel( + provider="autogen_ext.models.anthropic.AnthropicChatCompletionClient", + config={ + "model": model_name, + "api_key": "test-key" + } + ) + assert config.config["model"] == model_name + + +class TestAnthropicConfigSerialization: + """Test Anthropic configuration serialization/deserialization.""" + + def test_anthropic_config_yaml_serialization(self): + """Test Anthropic configuration can be serialized to/from YAML.""" + import yaml + from magentic_ui.magentic_ui_config import MagenticUIConfig, ModelClientConfigs + + config_dict = { + "model_client_configs": { + "orchestrator": { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114", + "api_key": "test-key", + "max_retries": 5 + } + } + } + } + + # Test serialization + yaml_str = yaml.safe_dump(config_dict) + assert "claude-4-sonnet-20251114" in yaml_str + assert "AnthropicChatCompletionClient" in yaml_str + + # Test deserialization + loaded_dict = yaml.safe_load(yaml_str) + config = MagenticUIConfig(**loaded_dict) + + assert config.model_client_configs.orchestrator.provider == "autogen_ext.models.anthropic.AnthropicChatCompletionClient" + assert config.model_client_configs.orchestrator.config["model"] == "claude-4-sonnet-20251114" + + def test_anthropic_config_json_serialization(self): + """Test Anthropic configuration can be serialized to/from JSON.""" + import json + from magentic_ui.magentic_ui_config import MagenticUIConfig + + config_dict = { + "model_client_configs": { + "web_surfer": { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-3-5-sonnet-20241022", + "api_key": "test-key" + } + } + } + } + + # Test JSON round-trip + json_str = json.dumps(config_dict) + loaded_dict = json.loads(json_str) + config = MagenticUIConfig(**loaded_dict) + + assert config.model_client_configs.web_surfer.provider == "autogen_ext.models.anthropic.AnthropicChatCompletionClient" + assert config.model_client_configs.web_surfer.config["model"] == "claude-3-5-sonnet-20241022" + + def test_mixed_providers_config(self): + """Test configuration with both OpenAI and Anthropic providers.""" + from magentic_ui.magentic_ui_config import MagenticUIConfig + + config_dict = { + "model_client_configs": { + "orchestrator": { + "provider": "OpenAIChatCompletionClient", + "config": { + "model": "gpt-4o-2024-08-06", + "api_key": "openai-key" + } + }, + "web_surfer": { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114", + "api_key": "anthropic-key" + } + } + } + } + + config = MagenticUIConfig(**config_dict) + + # Verify mixed providers work + assert config.model_client_configs.orchestrator.provider == "OpenAIChatCompletionClient" + assert config.model_client_configs.web_surfer.provider == "autogen_ext.models.anthropic.AnthropicChatCompletionClient" + + # Verify model assignments + assert config.model_client_configs.orchestrator.config["model"] == "gpt-4o-2024-08-06" + assert config.model_client_configs.web_surfer.config["model"] == "claude-4-sonnet-20251114" + + +class TestAnthropicErrorHandling: + """Test error handling for Anthropic integration.""" + + def test_anthropic_import_error_handling(self): + """Test graceful handling when Anthropic extension is not available.""" + # This test is tricky because we actually have Anthropic installed + # Instead, test with a non-existent provider + from magentic_ui.backend.web.routes.validation import ValidationService + + error = ValidationService.validate_provider("autogen_ext.models.nonexistent.NonExistentClient") + assert error is not None + assert "Could not import provider" in error.error or "Error validating provider" in error.error + + def test_anthropic_invalid_api_key_format(self): + """Test handling of invalid API key formats.""" + config = ComponentModel( + provider="autogen_ext.models.anthropic.AnthropicChatCompletionClient", + config={ + "model": "claude-4-sonnet-20251114", + "api_key": "invalid-key-format" # Should be sk-ant-xxx format + } + ) + + # This should not raise an exception at config creation time + # API key validation happens at runtime + assert config.config["api_key"] == "invalid-key-format" + + def test_anthropic_missing_required_fields(self): + """Test handling when required fields are missing.""" + # ComponentModel doesn't validate required fields - that's done by validation service + config = ComponentModel( + provider="autogen_ext.models.anthropic.AnthropicChatCompletionClient", + config={} # Missing model and api_key + ) + assert config.config == {} \ No newline at end of file diff --git a/tests/test_anthropic_validation.py b/tests/test_anthropic_validation.py new file mode 100644 index 00000000..e5bac862 --- /dev/null +++ b/tests/test_anthropic_validation.py @@ -0,0 +1,224 @@ +import pytest +from unittest.mock import patch, MagicMock +from magentic_ui.backend.web.routes.validation import ValidationService, ValidationError +from autogen_core import ComponentModel + + +class TestAnthropicValidation: + """Test Anthropic provider validation functionality.""" + + def test_anthropic_provider_mapping(self): + """Test that Anthropic provider aliases are correctly mapped.""" + component = { + "provider": "anthropic_chat_completion_client", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114", + "api_key": "test-key" + } + } + + with patch('magentic_ui.backend.web.routes.validation.importlib.import_module') as mock_import: + with patch('magentic_ui.backend.web.routes.validation.getattr') as mock_getattr: + with patch('magentic_ui.backend.web.routes.validation.is_component_class') as mock_is_component: + # Setup mocks + mock_component_class = MagicMock() + mock_import.return_value = MagicMock() + mock_getattr.return_value = mock_component_class + mock_is_component.return_value = True + + # Test provider validation + error = ValidationService.validate_provider(component["provider"]) + + # Verify mapping occurred + mock_import.assert_called_with("autogen_ext.models.anthropic") + mock_getattr.assert_called_with(mock_import.return_value, "AnthropicChatCompletionClient") + assert error is None + + def test_anthropic_provider_alias_mapping(self): + """Test AnthropicChatCompletionClient alias mapping.""" + component = { + "provider": "AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114" + } + } + + with patch('magentic_ui.backend.web.routes.validation.importlib.import_module') as mock_import: + with patch('magentic_ui.backend.web.routes.validation.getattr') as mock_getattr: + with patch('magentic_ui.backend.web.routes.validation.is_component_class') as mock_is_component: + mock_component_class = MagicMock() + mock_import.return_value = MagicMock() + mock_getattr.return_value = mock_component_class + mock_is_component.return_value = True + + error = ValidationService.validate_provider(component["provider"]) + + mock_import.assert_called_with("autogen_ext.models.anthropic") + mock_getattr.assert_called_with(mock_import.return_value, "AnthropicChatCompletionClient") + assert error is None + + def test_anthropic_full_provider_path(self): + """Test full Anthropic provider path validation.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114" + } + } + + with patch('magentic_ui.backend.web.routes.validation.importlib.import_module') as mock_import: + with patch('magentic_ui.backend.web.routes.validation.getattr') as mock_getattr: + with patch('magentic_ui.backend.web.routes.validation.is_component_class') as mock_is_component: + mock_component_class = MagicMock() + mock_import.return_value = MagicMock() + mock_getattr.return_value = mock_component_class + mock_is_component.return_value = True + + error = ValidationService.validate_provider(component["provider"]) + + mock_import.assert_called_with("autogen_ext.models.anthropic") + mock_getattr.assert_called_with(mock_import.return_value, "AnthropicChatCompletionClient") + assert error is None + + def test_anthropic_provider_import_error(self): + """Test handling of Anthropic provider import errors.""" + component = { + "provider": "anthropic_chat_completion_client", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114" + } + } + + with patch('src.magentic_ui.backend.web.routes.validation.importlib.import_module') as mock_import: + mock_import.side_effect = ImportError("Module not found") + + error = ValidationService.validate_provider(component["provider"]) + + assert error is not None + assert isinstance(error, ValidationError) + assert "Could not import provider" in error.error + assert "autogen_ext.models.anthropic.AnthropicChatCompletionClient" in error.error + + def test_anthropic_config_validation(self): + """Test Anthropic configuration validation.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114", + "api_key": "test-key", + "max_retries": 5 + } + } + + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_provider') as mock_validate_provider: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_component_type') as mock_validate_type: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_config_schema') as mock_validate_schema: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_instantiation') as mock_validate_inst: + # Setup mocks to return no errors + mock_validate_provider.return_value = None + mock_validate_type.return_value = None + mock_validate_schema.return_value = [] + mock_validate_inst.return_value = None + + result = ValidationService.validate(component) + + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_anthropic_invalid_config(self): + """Test validation with invalid Anthropic configuration.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + # Missing required model field + "api_key": "test-key" + } + } + + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_provider') as mock_validate_provider: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_component_type') as mock_validate_type: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_config_schema') as mock_validate_schema: + mock_validate_provider.return_value = None + mock_validate_type.return_value = None + mock_validate_schema.return_value = [ + ValidationError( + field="config.model", + error="Model field is required", + suggestion="Add a model field to the configuration" + ) + ] + + result = ValidationService.validate(component) + + assert result.is_valid is False + assert len(result.errors) == 1 + assert result.errors[0].field == "config.model" + + +class TestAnthropicModels: + """Test Anthropic model configurations.""" + + @pytest.mark.parametrize("model_name", [ + "claude-4-sonnet-20251114", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + "claude-3-opus-20240229" + ]) + def test_supported_anthropic_models(self, model_name): + """Test that various Anthropic models are properly configured.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": model_name, + "api_key": "test-key" + } + } + + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_provider') as mock_validate_provider: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_component_type') as mock_validate_type: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_config_schema') as mock_validate_schema: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_instantiation') as mock_validate_inst: + mock_validate_provider.return_value = None + mock_validate_type.return_value = None + mock_validate_schema.return_value = [] + mock_validate_inst.return_value = None + + result = ValidationService.validate(component) + + assert result.is_valid is True + assert len(result.errors) == 0 + + def test_anthropic_with_optional_config(self): + """Test Anthropic configuration with optional parameters.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114", + "api_key": "test-key", + "base_url": "https://api.anthropic.com", + "max_retries": 3, + "timeout": 30 + } + } + + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_provider') as mock_validate_provider: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_component_type') as mock_validate_type: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_config_schema') as mock_validate_schema: + with patch('magentic_ui.backend.web.routes.validation.ValidationService.validate_instantiation') as mock_validate_inst: + mock_validate_provider.return_value = None + mock_validate_type.return_value = None + mock_validate_schema.return_value = [] + mock_validate_inst.return_value = None + + result = ValidationService.validate(component) + + assert result.is_valid is True + assert len(result.errors) == 0 \ No newline at end of file diff --git a/tests/test_anthropic_validation_simple.py b/tests/test_anthropic_validation_simple.py new file mode 100644 index 00000000..4bb92a4e --- /dev/null +++ b/tests/test_anthropic_validation_simple.py @@ -0,0 +1,83 @@ +import pytest +from magentic_ui.backend.web.routes.validation import ValidationService, ValidationError +from autogen_core import ComponentModel + + +class TestAnthropicValidation: + """Test Anthropic provider validation functionality.""" + + def test_anthropic_provider_validation_real(self): + """Test that Anthropic provider can be validated.""" + # Test with full provider path + error = ValidationService.validate_provider("autogen_ext.models.anthropic.AnthropicChatCompletionClient") + assert error is None # Should pass since we have anthropic installed + + def test_anthropic_alias_validation(self): + """Test Anthropic provider aliases.""" + # Test anthropic_chat_completion_client alias + error = ValidationService.validate_provider("anthropic_chat_completion_client") + assert error is None + + # Test AnthropicChatCompletionClient alias + error = ValidationService.validate_provider("AnthropicChatCompletionClient") + assert error is None + + def test_invalid_provider_validation(self): + """Test validation with invalid provider.""" + error = ValidationService.validate_provider("NonExistentProvider") + assert error is not None + assert isinstance(error, ValidationError) + assert "Error validating provider" in error.error + + def test_anthropic_component_validation(self): + """Test full Anthropic component validation.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114" + } + } + + result = ValidationService.validate(component) + # May have warnings but should be valid + assert result.is_valid is True + + +class TestAnthropicModels: + """Test Anthropic model configurations.""" + + @pytest.mark.parametrize("model_name", [ + "claude-4-sonnet-20251114", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + "claude-3-opus-20240229" + ]) + def test_supported_anthropic_models(self, model_name): + """Test that various Anthropic models are properly configured.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": model_name + } + } + + result = ValidationService.validate(component) + assert result.is_valid is True + + def test_anthropic_with_optional_config(self): + """Test Anthropic configuration with optional parameters.""" + component = { + "provider": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "component_type": "autogen_ext.models.anthropic.AnthropicChatCompletionClient", + "config": { + "model": "claude-4-sonnet-20251114", + "api_key": "test-key", + "base_url": "https://api.anthropic.com", + "max_retries": 3 + } + } + + result = ValidationService.validate(component) + assert result.is_valid is True \ No newline at end of file diff --git a/tests/test_magentic_ui_config_serialization.py b/tests/test_magentic_ui_config_serialization.py index 331c99fe..678bddb7 100644 --- a/tests/test_magentic_ui_config_serialization.py +++ b/tests/test_magentic_ui_config_serialization.py @@ -11,8 +11,14 @@ config: model: gpt-4.1-2025-04-14 max_retries: 10 + anthropic_client: &anthropic_client + provider: autogen_ext.models.anthropic.AnthropicChatCompletionClient + config: + model: claude-4-sonnet-20251114 + api_key: test-key + max_retries: 5 orchestrator: *default_client - web_surfer: *default_client + web_surfer: *anthropic_client coder: *default_client file_surfer: *default_client action_guard: @@ -113,3 +119,105 @@ def test_json_and_yaml_equivalence(yaml_config_text: str) -> None: config = MagenticUIConfig(**loaded) assert config.task == "What tools are available?" assert config.mcp_agent_configs[0].name == "mcp_agent" + + +def test_anthropic_config_serialization(config_obj: MagenticUIConfig) -> None: + """Test that Anthropic configuration serializes and deserializes correctly.""" + # Verify Anthropic client is present in config + assert config_obj.model_client_configs.web_surfer is not None + web_surfer_config = config_obj.model_client_configs.web_surfer + assert web_surfer_config.provider == "autogen_ext.models.anthropic.AnthropicChatCompletionClient" + assert web_surfer_config.config["model"] == "claude-4-sonnet-20251114" + assert web_surfer_config.config["api_key"] == "test-key" + # max_retries is handled at the ModelClientConfigs level, not ComponentModel level + + +def test_mixed_providers_serialization() -> None: + """Test serialization with mixed OpenAI and Anthropic providers.""" + mixed_config = """ +model_client_configs: + orchestrator: + provider: OpenAIChatCompletionClient + config: + model: gpt-4o-2024-08-06 + api_key: openai-key + max_retries: 10 + web_surfer: + provider: autogen_ext.models.anthropic.AnthropicChatCompletionClient + config: + model: claude-4-sonnet-20251114 + api_key: anthropic-key + max_retries: 5 + coder: + provider: autogen_ext.models.anthropic.AnthropicChatCompletionClient + config: + model: claude-3-5-sonnet-20241022 + api_key: anthropic-key-2 + max_retries: 3 +cooperative_planning: true +autonomous_execution: false +task: "Test mixed providers" +""" + + data = yaml.safe_load(mixed_config) + config = MagenticUIConfig(**data) + + # Verify each provider type + assert config.model_client_configs.orchestrator.provider == "OpenAIChatCompletionClient" + assert config.model_client_configs.orchestrator.config["model"] == "gpt-4o-2024-08-06" + + assert config.model_client_configs.web_surfer.provider == "autogen_ext.models.anthropic.AnthropicChatCompletionClient" + assert config.model_client_configs.web_surfer.config["model"] == "claude-4-sonnet-20251114" + + assert config.model_client_configs.coder.provider == "autogen_ext.models.anthropic.AnthropicChatCompletionClient" + assert config.model_client_configs.coder.config["model"] == "claude-3-5-sonnet-20241022" + + # Test round-trip serialization + as_dict = config.model_dump(mode="json") + yaml_text = yaml.safe_dump(as_dict) + loaded = yaml.safe_load(yaml_text) + config2 = MagenticUIConfig(**loaded) + assert config2 == config + + +def test_anthropic_with_optional_fields() -> None: + """Test Anthropic configuration with optional fields.""" + anthropic_config = """ +model_client_configs: + orchestrator: + provider: autogen_ext.models.anthropic.AnthropicChatCompletionClient + config: + model: claude-4-sonnet-20251114 + api_key: test-key + base_url: https://custom-anthropic-endpoint.com + max_retries: 3 + timeout: 30 + model_info: + vision: true + function_calling: true + json_output: false + family: claude-4-sonnet + structured_output: false + multiple_system_messages: false + max_retries: 5 +task: "Test Anthropic with optional fields" +""" + + data = yaml.safe_load(anthropic_config) + config = MagenticUIConfig(**data) + + orchestrator_config = config.model_client_configs.orchestrator + assert orchestrator_config.config["base_url"] == "https://custom-anthropic-endpoint.com" + assert orchestrator_config.config["timeout"] == 30 + assert orchestrator_config.config["model_info"]["vision"] is True + assert orchestrator_config.config["model_info"]["family"] == "claude-4-sonnet" + + # Test serialization preserves optional fields + as_dict = config.model_dump(mode="json") + json_text = json.dumps(as_dict) + loaded = json.loads(json_text) + config2 = MagenticUIConfig(**loaded) + + orchestrator_config2 = config2.model_client_configs.orchestrator + assert orchestrator_config2.config["base_url"] == "https://custom-anthropic-endpoint.com" + assert orchestrator_config2.config["model_info"]["family"] == "claude-4-sonnet"