diff --git a/Elsa.Extensions.sln b/Elsa.Extensions.sln index fdf72b48..fc9e2d27 100644 --- a/Elsa.Extensions.sln +++ b/Elsa.Extensions.sln @@ -277,6 +277,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "diagnostics", "diagnostics" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.OpenTelemetry", "src\modules\diagnostics\Elsa.OpenTelemetry\Elsa.OpenTelemetry.csproj", "{28D04FA3-4DCC-4137-8ED4-9F6F1A815909}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ai", "ai", "{E0C1E9D1-ECAF-4C86-B623-E3C748E82BCF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Integrations.AnthropicClaude", "src\modules\ai\Elsa.Integrations.AnthropicClaude\Elsa.Integrations.AnthropicClaude.csproj", "{A83B6B99-58AA-4DB2-801A-514361789759}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ai", "ai", "{5F052B47-53DB-43B8-852D-D52DC967DE7B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Integrations.AnthropicClaude.Tests", "test\modules\ai\Elsa.Integrations.AnthropicClaude.Tests\Elsa.Integrations.AnthropicClaude.Tests.csproj", "{793F0184-6F08-4D9A-8EC2-16A5560AD9B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -631,6 +639,14 @@ Global {28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Debug|Any CPU.Build.0 = Debug|Any CPU {28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Release|Any CPU.ActiveCfg = Release|Any CPU {28D04FA3-4DCC-4137-8ED4-9F6F1A815909}.Release|Any CPU.Build.0 = Release|Any CPU + {A83B6B99-58AA-4DB2-801A-514361789759}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A83B6B99-58AA-4DB2-801A-514361789759}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A83B6B99-58AA-4DB2-801A-514361789759}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A83B6B99-58AA-4DB2-801A-514361789759}.Release|Any CPU.Build.0 = Release|Any CPU + {793F0184-6F08-4D9A-8EC2-16A5560AD9B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {793F0184-6F08-4D9A-8EC2-16A5560AD9B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {793F0184-6F08-4D9A-8EC2-16A5560AD9B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {793F0184-6F08-4D9A-8EC2-16A5560AD9B7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -755,6 +771,10 @@ Global {8D0EC628-350F-47FC-8B36-DD98B4B7CC93} = {30CF0330-4B09-4784-B499-46BED303810B} {28D04FA3-4DCC-4137-8ED4-9F6F1A815909} = {8D0EC628-350F-47FC-8B36-DD98B4B7CC93} {04265302-AF6B-4627-807C-DE9E1699D7C9} = {30CF0330-4B09-4784-B499-46BED303810B} + {E0C1E9D1-ECAF-4C86-B623-E3C748E82BCF} = {30CF0330-4B09-4784-B499-46BED303810B} + {A83B6B99-58AA-4DB2-801A-514361789759} = {E0C1E9D1-ECAF-4C86-B623-E3C748E82BCF} + {5F052B47-53DB-43B8-852D-D52DC967DE7B} = {3DDE6F89-531C-47F8-9CD7-7A4E6984FA48} + {793F0184-6F08-4D9A-8EC2-16A5560AD9B7} = {5F052B47-53DB-43B8-852D-D52DC967DE7B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {11A771DA-B728-445E-8A88-AE1C84C3B3A6} diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/ClaudeActivity.cs b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/ClaudeActivity.cs new file mode 100644 index 00000000..b79b5cc4 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/ClaudeActivity.cs @@ -0,0 +1,32 @@ +using Elsa.Integrations.AnthropicClaude.Services; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; + +namespace Elsa.Integrations.AnthropicClaude.Activities; + +/// +/// Base class for all Claude-related activities. +/// +public abstract class ClaudeActivity : Activity +{ + /// + /// The Anthropic Claude API key. + /// + [Input( + Description = "The Anthropic Claude API key. Get your API key from https://console.anthropic.com/", + UIHint = "password")] + public Input ApiKey { get; set; } = null!; + + /// + /// Gets a configured Claude API client. + /// + /// The activity execution context. + /// A configured Claude API client. + protected ClaudeApiClient GetClient(ActivityExecutionContext context) + { + var clientFactory = context.GetRequiredService(); + var apiKey = context.Get(ApiKey)!; + return clientFactory.GetClient(apiKey); + } +} \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/CreatePrompt.cs b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/CreatePrompt.cs new file mode 100644 index 00000000..4bf4dc09 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/CreatePrompt.cs @@ -0,0 +1,173 @@ +using Elsa.Integrations.AnthropicClaude.Models; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using JetBrains.Annotations; + +namespace Elsa.Integrations.AnthropicClaude.Activities; + +/// +/// Creates a prompt and sends it to Claude AI for completion. +/// +[Activity( + "Elsa.AnthropicClaude.Completions", + "Anthropic Claude", + "Creates a prompt based on structured input messages and gets a response from Claude AI.", + DisplayName = "Create Prompt")] +[UsedImplicitly] +public class CreatePrompt : ClaudeActivity +{ + /// + /// The model to use for the completion (e.g., claude-3-sonnet-20240229, claude-3-haiku-20240307). + /// + [Input( + Description = "The Claude model to use for completion. Popular options: claude-3-sonnet-20240229, claude-3-haiku-20240307, claude-3-opus-20240229", + DefaultValue = "claude-3-sonnet-20240229")] + public Input Model { get; set; } = new("claude-3-sonnet-20240229"); + + /// + /// The system prompt that defines Claude's behavior and context. + /// + [Input( + Description = "System prompt that defines Claude's behavior, role, and context for the conversation.", + UIHint = "multiline")] + public Input SystemPrompt { get; set; } = null!; + + /// + /// The user message or prompt to send to Claude. + /// + [Input( + Description = "The user message or prompt to send to Claude.", + UIHint = "multiline")] + public Input UserMessage { get; set; } = null!; + + /// + /// Previous messages in the conversation (JSON array of messages with 'role' and 'content' properties). + /// + [Input( + Description = "Previous messages in the conversation as a JSON array. Each message should have 'role' (user/assistant) and 'content' properties.", + UIHint = "multiline")] + public Input PreviousMessages { get; set; } = null!; + + /// + /// Maximum number of tokens to generate in the response. + /// + [Input( + Description = "Maximum number of tokens to generate in the response (1-4096).", + DefaultValue = 1024)] + public Input MaxTokens { get; set; } = new(1024); + + /// + /// Temperature for controlling randomness (0.0 = focused, 1.0 = creative). + /// + [Input( + Description = "Temperature for controlling response randomness. 0.0 = very focused, 1.0 = very creative.", + DefaultValue = 0.7)] + public Input Temperature { get; set; } = new(0.7); + + /// + /// Stop sequences that will end the generation when encountered. + /// + [Input( + Description = "Stop sequences that will end generation when encountered (comma-separated).")] + public Input StopSequences { get; set; } = null!; + + /// + /// The generated response from Claude. + /// + [Output(Description = "The text response generated by Claude.")] + public Output Response { get; set; } = null!; + + /// + /// The full response object containing additional metadata. + /// + [Output(Description = "The complete response object with metadata including token usage.")] + public Output FullResponse { get; set; } = null!; + + /// + /// The number of input tokens used. + /// + [Output(Description = "The number of input tokens used for this request.")] + public Output InputTokens { get; set; } = null!; + + /// + /// The number of output tokens generated. + /// + [Output(Description = "The number of output tokens generated in the response.")] + public Output OutputTokens { get; set; } = null!; + + /// + /// Executes the activity. + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var model = context.Get(Model)!; + var systemPrompt = context.Get(SystemPrompt); + var userMessage = context.Get(UserMessage)!; + var previousMessagesJson = context.Get(PreviousMessages); + var maxTokens = context.Get(MaxTokens); + var temperature = context.Get(Temperature); + var stopSequencesInput = context.Get(StopSequences); + + var client = GetClient(context); + + // Build the messages list + var messages = new List(); + + // Add previous messages if provided + if (!string.IsNullOrWhiteSpace(previousMessagesJson)) + { + try + { + var previousMessages = System.Text.Json.JsonSerializer.Deserialize>(previousMessagesJson); + if (previousMessages != null) + { + messages.AddRange(previousMessages); + } + } + catch (System.Text.Json.JsonException) + { + // If JSON parsing fails, ignore previous messages and continue + } + } + + // Add the current user message + messages.Add(new ClaudeMessage + { + Role = "user", + Content = userMessage + }); + + // Parse stop sequences + List? stopSequences = null; + if (!string.IsNullOrWhiteSpace(stopSequencesInput)) + { + stopSequences = stopSequencesInput.Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .ToList(); + } + + // Create the request + var request = new ClaudeCompletionRequest + { + Model = model, + MaxTokens = maxTokens, + Messages = messages, + System = systemPrompt, + Temperature = temperature, + StopSequences = stopSequences + }; + + // Make the API call + var response = await client.CreateCompletionAsync(request, context.CancellationToken); + + // Extract the response text + var responseText = response.Content.FirstOrDefault()?.Text ?? string.Empty; + + // Set outputs + context.Set(Response, responseText); + context.Set(FullResponse, response); + context.Set(InputTokens, response.Usage?.InputTokens ?? 0); + context.Set(OutputTokens, response.Usage?.OutputTokens ?? 0); + } +} \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/MakeAPICall.cs b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/MakeAPICall.cs new file mode 100644 index 00000000..a4fb70b1 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Activities/MakeAPICall.cs @@ -0,0 +1,122 @@ +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using JetBrains.Annotations; + +namespace Elsa.Integrations.AnthropicClaude.Activities; + +/// +/// Makes an arbitrary HTTP call to the Anthropic Claude API. +/// +[Activity( + "Elsa.AnthropicClaude.API", + "Anthropic Claude", + "Performs an arbitrary authorized API call to the Anthropic Claude API.", + DisplayName = "Make API Call")] +[UsedImplicitly] +public class MakeAPICall : ClaudeActivity +{ + /// + /// The HTTP method to use for the API call. + /// + [Input( + Description = "The HTTP method to use (GET, POST, PUT, DELETE, etc.).", + DefaultValue = "POST")] + public Input HttpMethod { get; set; } = new("POST"); + + /// + /// The API endpoint to call (relative to the Claude API base URL). + /// + [Input( + Description = "The API endpoint to call, relative to https://api.anthropic.com/v1/ (e.g., 'messages', 'models').", + DefaultValue = "messages")] + public Input Endpoint { get; set; } = new("messages"); + + /// + /// The request body as JSON string (for POST, PUT requests). + /// + [Input( + Description = "The request body as a JSON string. Leave empty for GET requests.", + UIHint = "multiline")] + public Input RequestBody { get; set; } = null!; + + /// + /// Whether to validate that the request body is valid JSON before sending. + /// + [Input( + Description = "Whether to validate that the request body is valid JSON before sending the request.", + DefaultValue = true)] + public Input ValidateJson { get; set; } = new(true); + + /// + /// The raw response body from the API call. + /// + [Output(Description = "The raw response body from the Claude API call.")] + public Output ResponseBody { get; set; } = null!; + + /// + /// Indicates whether the API call was successful (2xx status code). + /// + [Output(Description = "True if the API call was successful (2xx status code), false otherwise.")] + public Output Success { get; set; } = null!; + + /// + /// Executes the activity. + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var httpMethodString = context.Get(HttpMethod)!; + var endpoint = context.Get(Endpoint)!; + var requestBody = context.Get(RequestBody); + var validateJson = context.Get(ValidateJson); + + var client = GetClient(context); + + // Validate HTTP method + if (!System.Net.Http.HttpMethod.TryParse(httpMethodString, out var method)) + { + throw new ArgumentException($"Invalid HTTP method: {httpMethodString}"); + } + + // Validate JSON if requested and content is provided + if (validateJson && !string.IsNullOrWhiteSpace(requestBody)) + { + try + { + System.Text.Json.JsonDocument.Parse(requestBody); + } + catch (System.Text.Json.JsonException ex) + { + throw new ArgumentException($"Invalid JSON in request body: {ex.Message}", ex); + } + } + + // Ensure endpoint doesn't start with slash (we'll add it in the service) + if (endpoint.StartsWith("/")) + { + endpoint = endpoint.Substring(1); + } + + try + { + // Make the API call + var responseBody = await client.MakeApiCallAsync( + method, + endpoint, + requestBody, + context.CancellationToken); + + // Set outputs + context.Set(ResponseBody, responseBody); + context.Set(Success, true); + } + catch (HttpRequestException) + { + // For HTTP errors, we still want to set Success to false but not throw + // The error details should be in the exception message + context.Set(ResponseBody, string.Empty); + context.Set(Success, false); + throw; // Re-throw to let the workflow handle the error + } + } +} \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Elsa.Integrations.AnthropicClaude.csproj b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Elsa.Integrations.AnthropicClaude.csproj new file mode 100644 index 00000000..3e228ea3 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Elsa.Integrations.AnthropicClaude.csproj @@ -0,0 +1,18 @@ +ο»Ώ + + + net8.0 + + + Provides integration with Anthropic Claude AI for Elsa Workflows. + + elsa extension module anthropic claude ai + + + + + + + + + \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Extensions/ModuleExtensions.cs b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Extensions/ModuleExtensions.cs new file mode 100644 index 00000000..67363c03 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Extensions/ModuleExtensions.cs @@ -0,0 +1,22 @@ +using Elsa.Features.Abstractions; +using Elsa.Integrations.AnthropicClaude.Features; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Extensions for configuring Anthropic Claude integration. +/// +public static class ModuleExtensions +{ + /// + /// Adds Anthropic Claude integration to Elsa. + /// + /// The Elsa module. + /// The module for further configuration. + public static IModule UseAnthropicClaude(this IModule module) + { + module.Configure(); + return module; + } +} \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Features/AnthropicClaudeFeature.cs b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Features/AnthropicClaudeFeature.cs new file mode 100644 index 00000000..933faa00 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Features/AnthropicClaudeFeature.cs @@ -0,0 +1,27 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.Integrations.AnthropicClaude.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Integrations.AnthropicClaude.Features; + +/// +/// Feature for setting up Anthropic Claude integration within the Elsa framework. +/// +public class AnthropicClaudeFeature(IModule module) : FeatureBase(module) +{ + /// + /// Applies the feature to the specified service collection. + /// + public override void Apply() + { + Services + .AddHttpClient("Claude", client => + { + client.BaseAddress = new Uri("https://api.anthropic.com/v1/"); + client.Timeout = TimeSpan.FromMinutes(5); // Claude API calls can take time + }) + .Services + .AddSingleton(); + } +} \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/FodyWeavers.xml b/src/modules/ai/Elsa.Integrations.AnthropicClaude/FodyWeavers.xml new file mode 100644 index 00000000..822ae5e5 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/FodyWeavers.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Models/ClaudeModels.cs b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Models/ClaudeModels.cs new file mode 100644 index 00000000..e48aa4cd --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Models/ClaudeModels.cs @@ -0,0 +1,153 @@ +using System.Text.Json.Serialization; + +namespace Elsa.Integrations.AnthropicClaude.Models; + +/// +/// Represents a message in a Claude conversation. +/// +public class ClaudeMessage +{ + /// + /// The role of the message sender (user, assistant, or system). + /// + [JsonPropertyName("role")] + public string Role { get; set; } = null!; + + /// + /// The content of the message. + /// + [JsonPropertyName("content")] + public string Content { get; set; } = null!; +} + +/// +/// Represents a request to the Claude API for creating completions. +/// +public class ClaudeCompletionRequest +{ + /// + /// The model to use for the completion. + /// + [JsonPropertyName("model")] + public string Model { get; set; } = "claude-3-sonnet-20240229"; + + /// + /// Maximum number of tokens to generate. + /// + [JsonPropertyName("max_tokens")] + public int MaxTokens { get; set; } = 1024; + + /// + /// The messages to send to Claude. + /// + [JsonPropertyName("messages")] + public List Messages { get; set; } = new(); + + /// + /// System prompt for the conversation. + /// + [JsonPropertyName("system")] + public string? System { get; set; } + + /// + /// Temperature for response randomness (0.0 to 1.0). + /// + [JsonPropertyName("temperature")] + public double? Temperature { get; set; } + + /// + /// Stop sequences to end generation. + /// + [JsonPropertyName("stop_sequences")] + public List? StopSequences { get; set; } +} + +/// +/// Represents the content of a Claude API response. +/// +public class ClaudeResponseContent +{ + /// + /// The type of content (usually "text"). + /// + [JsonPropertyName("type")] + public string Type { get; set; } = null!; + + /// + /// The text content of the response. + /// + [JsonPropertyName("text")] + public string Text { get; set; } = null!; +} + +/// +/// Represents a response from the Claude API. +/// +public class ClaudeCompletionResponse +{ + /// + /// The unique identifier for this response. + /// + [JsonPropertyName("id")] + public string Id { get; set; } = null!; + + /// + /// The type of object returned. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = null!; + + /// + /// The role of the responder (should be "assistant"). + /// + [JsonPropertyName("role")] + public string Role { get; set; } = null!; + + /// + /// The content of the response. + /// + [JsonPropertyName("content")] + public List Content { get; set; } = new(); + + /// + /// The model that generated this response. + /// + [JsonPropertyName("model")] + public string Model { get; set; } = null!; + + /// + /// The reason the response ended. + /// + [JsonPropertyName("stop_reason")] + public string? StopReason { get; set; } + + /// + /// The stop sequence that ended the response, if any. + /// + [JsonPropertyName("stop_sequence")] + public string? StopSequence { get; set; } + + /// + /// Usage statistics for this response. + /// + [JsonPropertyName("usage")] + public ClaudeUsage? Usage { get; set; } +} + +/// +/// Represents usage statistics from a Claude API response. +/// +public class ClaudeUsage +{ + /// + /// Number of input tokens used. + /// + [JsonPropertyName("input_tokens")] + public int InputTokens { get; set; } + + /// + /// Number of output tokens generated. + /// + [JsonPropertyName("output_tokens")] + public int OutputTokens { get; set; } +} \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/README.md b/src/modules/ai/Elsa.Integrations.AnthropicClaude/README.md new file mode 100644 index 00000000..ac2567d0 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/README.md @@ -0,0 +1,212 @@ +# Elsa.Integrations.AnthropicClaude + +This package extends [Elsa Workflows](https://github.com/elsa-workflows/elsa-core) with support for **Anthropic Claude AI**. It introduces custom activities that make it easy to integrate Claude's powerful language models directly into your workflow logic. + +## ✨ Key Features + +- **CreatePrompt** - Creates structured prompts and gets AI-generated responses from Claude +- **MakeAPICall** - Performs arbitrary authorized API calls to the Anthropic Claude API +- Support for all Claude models (Claude-3 Sonnet, Haiku, Opus) +- Configurable temperature, token limits, and stop sequences +- Comprehensive error handling and logging +- Token usage tracking for cost monitoring + +--- + +## ⚑ Getting Started + +### πŸ“‹ Prerequisites + +- Elsa Workflows **3.7.0+** installed in your project +- Anthropic Claude API key from [Anthropic Console](https://console.anthropic.com/) + +### πŸ›  Installation + +Add the Anthropic Claude extension to your project: + +```bash +dotnet add package Elsa.Integrations.AnthropicClaude +``` + +### πŸ”§ Registration + +Register the Anthropic Claude extension in your Elsa builder: + +```csharp +// Program.cs or Startup.cs +services + .AddElsa(elsa => + { + elsa.UseAnthropicClaude(); + // Other Elsa configurations... + }); +``` + +## πŸ” Authentication + +The Claude activities require an API key from Anthropic for authentication. You can obtain an API key from the [Anthropic Console](https://console.anthropic.com/). + +**Important**: Store your API key securely using Elsa's secrets management system or environment variables. Never hard-code API keys in your workflows. + +--- + +## πŸ›  Available Activities + +### CreatePrompt + +Creates a structured prompt and sends it to Claude AI for completion. + +**Inputs:** +- **ApiKey** (required) - Your Anthropic Claude API key +- **Model** - Claude model to use (default: claude-3-sonnet-20240229) +- **UserMessage** (required) - The user message/prompt to send +- **SystemPrompt** - System prompt that defines Claude's behavior +- **PreviousMessages** - JSON array of previous conversation messages +- **MaxTokens** - Maximum tokens to generate (default: 1024) +- **Temperature** - Response randomness 0.0-1.0 (default: 0.7) +- **StopSequences** - Comma-separated stop sequences + +**Outputs:** +- **Response** - The text response from Claude +- **FullResponse** - Complete response object with metadata +- **InputTokens** - Number of input tokens used +- **OutputTokens** - Number of output tokens generated + +### MakeAPICall + +Performs an arbitrary HTTP call to the Anthropic Claude API. + +**Inputs:** +- **ApiKey** (required) - Your Anthropic Claude API key +- **HttpMethod** - HTTP method to use (default: POST) +- **Endpoint** - API endpoint relative to base URL (default: messages) +- **RequestBody** - JSON request body for POST/PUT requests +- **ValidateJson** - Whether to validate JSON before sending (default: true) + +**Outputs:** +- **ResponseBody** - Raw response body from the API +- **Success** - Whether the call was successful (2xx status) + +--- + +## πŸ“š Example Usage + +### Basic Text Generation + +```csharp +// Workflow definition example +public class ClaudeWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + builder + .Root(prompt => prompt + .Set(x => x.ApiKey, "your-api-key-here") + .Set(x => x.UserMessage, "Explain quantum computing in simple terms") + .Set(x => x.SystemPrompt, "You are a helpful science teacher.") + .Set(x => x.MaxTokens, 500)) + .Then(write => write + .Set(x => x.Text, prompt => prompt.Response)); + } +} +``` + +### Conversation with Context + +```csharp +public class ConversationWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var previousMessages = """ + [ + {"role": "user", "content": "What is machine learning?"}, + {"role": "assistant", "content": "Machine learning is a subset of AI..."} + ] + """; + + builder + .Root(prompt => prompt + .Set(x => x.ApiKey, context => context.GetVariable("ClaudeApiKey")) + .Set(x => x.UserMessage, "Can you give me a practical example?") + .Set(x => x.PreviousMessages, previousMessages) + .Set(x => x.Temperature, 0.5)); + } +} +``` + +### Custom API Call + +```csharp +public class CustomApiWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var customRequest = """ + { + "model": "claude-3-haiku-20240307", + "max_tokens": 100, + "messages": [ + {"role": "user", "content": "Hello Claude!"} + ] + } + """; + + builder + .Root(call => call + .Set(x => x.ApiKey, "your-api-key-here") + .Set(x => x.Endpoint, "messages") + .Set(x => x.RequestBody, customRequest)) + .Then(write => write + .Set(x => x.Text, call => call.ResponseBody)); + } +} +``` + +--- + +## πŸ—’οΈ Notes & Best Practices + +### Model Selection +- **claude-3-opus-20240229** - Most capable, highest cost, slowest +- **claude-3-sonnet-20240229** - Balanced performance and cost (recommended) +- **claude-3-haiku-20240307** - Fastest, lowest cost, good for simple tasks + +### Token Management +- Monitor `InputTokens` and `OutputTokens` to track API usage costs +- Set appropriate `MaxTokens` limits to control response length +- Use shorter prompts when possible to reduce input token costs + +### Error Handling +- Activities will throw exceptions for API errors (invalid keys, rate limits, etc.) +- Use Elsa's error handling activities to gracefully handle failures +- Check the `Success` output on `MakeAPICall` for custom error handling + +### Security +- Always use Elsa's secrets management for API keys +- Never log or expose API keys in workflow outputs +- Consider using environment-specific API keys for different deployment stages + +--- + +## πŸ“– References + +- [Anthropic Claude API Documentation](https://docs.anthropic.com/) +- [Anthropic Console](https://console.anthropic.com/) +- [Claude Model Comparison](https://docs.anthropic.com/claude/docs/models-overview) +- [Elsa Workflows Documentation](https://elsa-workflows.github.io/elsa-core/) + +--- + +## πŸ—ΊοΈ Planned Features + +- [ ] Add streaming response support +- [ ] Add function calling capabilities +- [ ] Add image input support (when available) +- [ ] Add async retry/backoff support +- [ ] Add batch processing activities + +--- + +This extension was developed to add Anthropic Claude AI functionality to Elsa Workflows. +If you have ideas for improvement, encounter issues, or want to share how you're using it, feel free to open an issue or start a discussion! \ No newline at end of file diff --git a/src/modules/ai/Elsa.Integrations.AnthropicClaude/Services/ClaudeApiClient.cs b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Services/ClaudeApiClient.cs new file mode 100644 index 00000000..ca447cb1 --- /dev/null +++ b/src/modules/ai/Elsa.Integrations.AnthropicClaude/Services/ClaudeApiClient.cs @@ -0,0 +1,166 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using Elsa.Integrations.AnthropicClaude.Models; +using Microsoft.Extensions.Logging; + +namespace Elsa.Integrations.AnthropicClaude.Services; + +/// +/// Service for communicating with the Anthropic Claude API. +/// +public class ClaudeApiClient +{ + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private const string BaseUrl = "https://api.anthropic.com/v1"; + + /// + /// Initializes a new instance of the Claude API client. + /// + /// The HTTP client to use for API calls. + /// The logger instance. + public ClaudeApiClient(HttpClient httpClient, ILogger logger) + { + _httpClient = httpClient; + _logger = logger; + + // Set base address if not already set + if (_httpClient.BaseAddress == null) + { + _httpClient.BaseAddress = new Uri(BaseUrl); + } + } + + /// + /// Configures the HTTP client with the provided API key. + /// + /// The Anthropic API key. + public void SetApiKey(string apiKey) + { + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + } + + /// + /// Creates a completion using the Claude API. + /// + /// The completion request. + /// Cancellation token. + /// The completion response. + public async Task CreateCompletionAsync(ClaudeCompletionRequest request, CancellationToken cancellationToken = default) + { + try + { + var json = JsonSerializer.Serialize(request, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }); + + _logger.LogDebug("Sending Claude API request: {Request}", json); + + var content = new StringContent(json, Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync("/messages", content, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Claude API request failed with status {StatusCode}: {Error}", + response.StatusCode, errorContent); + throw new HttpRequestException($"Claude API request failed: {response.StatusCode} - {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogDebug("Received Claude API response: {Response}", responseJson); + + var result = JsonSerializer.Deserialize(responseJson, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }); + + return result ?? throw new InvalidOperationException("Failed to deserialize Claude API response"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while calling Claude API"); + throw; + } + } + + /// + /// Makes an arbitrary HTTP request to the Claude API. + /// + /// The HTTP method to use. + /// The API endpoint (relative to base URL). + /// The request content (JSON string). + /// Cancellation token. + /// The response content as a string. + public async Task MakeApiCallAsync(HttpMethod method, string endpoint, string? content = null, CancellationToken cancellationToken = default) + { + try + { + var request = new HttpRequestMessage(method, endpoint); + + if (!string.IsNullOrEmpty(content)) + { + request.Content = new StringContent(content, Encoding.UTF8, "application/json"); + } + + _logger.LogDebug("Making Claude API call: {Method} {Endpoint}", method, endpoint); + + var response = await _httpClient.SendAsync(request, cancellationToken); + var responseContent = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Claude API call failed with status {StatusCode}: {Error}", + response.StatusCode, responseContent); + throw new HttpRequestException($"Claude API call failed: {response.StatusCode} - {responseContent}"); + } + + _logger.LogDebug("Claude API call successful: {Response}", responseContent); + return responseContent; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while making Claude API call"); + throw; + } + } +} + +/// +/// Factory for creating Claude API clients with proper configuration. +/// +public class ClaudeClientFactory +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the Claude client factory. + /// + /// The HTTP client factory. + /// The logger instance. + public ClaudeClientFactory(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + /// + /// Creates a Claude API client configured with the specified API key. + /// + /// The Anthropic API key. + /// A configured Claude API client. + public ClaudeApiClient GetClient(string apiKey) + { + var httpClient = _httpClientFactory.CreateClient("Claude"); + var client = new ClaudeApiClient(httpClient, _logger); + client.SetApiKey(apiKey); + return client; + } +} \ No newline at end of file diff --git a/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeApiClientTests.cs b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeApiClientTests.cs new file mode 100644 index 00000000..fd32b5ba --- /dev/null +++ b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeApiClientTests.cs @@ -0,0 +1,80 @@ +using Elsa.Integrations.AnthropicClaude.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Elsa.Integrations.AnthropicClaude.Tests; + +/// +/// Tests for Claude API client functionality. +/// +public class ClaudeApiClientTests +{ + private readonly ClaudeApiClient _client; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + public ClaudeApiClientTests() + { + _httpClient = new HttpClient(); + _logger = Substitute.For>(); + _client = new ClaudeApiClient(_httpClient, _logger); + } + + [Fact] + public void SetApiKey_ConfiguresHttpClientHeaders_Correctly() + { + // Arrange + const string apiKey = "test-api-key"; + + // Act + _client.SetApiKey(apiKey); + + // Assert + Assert.Contains(_httpClient.DefaultRequestHeaders, h => + h.Key == "x-api-key" && h.Value.Contains(apiKey)); + Assert.Contains(_httpClient.DefaultRequestHeaders, h => + h.Key == "anthropic-version" && h.Value.Contains("2023-06-01")); + Assert.True(_httpClient.DefaultRequestHeaders.Accept.Any(a => + a.MediaType == "application/json")); + } + + [Fact] + public void Constructor_SetsBaseAddress_WhenNotAlreadySet() + { + // Arrange + var httpClient = new HttpClient(); + var logger = Substitute.For>(); + + // Act + var client = new ClaudeApiClient(httpClient, logger); + + // Assert + Assert.Equal("https://api.anthropic.com/v1/", httpClient.BaseAddress?.ToString()); + } + + [Fact] + public void Constructor_DoesNotOverrideBaseAddress_WhenAlreadySet() + { + // Arrange + var httpClient = new HttpClient { BaseAddress = new Uri("https://custom.api.com/") }; + var logger = Substitute.For>(); + + // Act + var client = new ClaudeApiClient(httpClient, logger); + + // Assert + Assert.Equal("https://custom.api.com/", httpClient.BaseAddress?.ToString()); + } + + [Fact] + public void Dispose_DisposesHttpClient() + { + // Arrange + var httpClient = new HttpClient(); + var logger = Substitute.For>(); + var client = new ClaudeApiClient(httpClient, logger); + + // Act & Assert - Should not throw + httpClient.Dispose(); + } +} \ No newline at end of file diff --git a/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeClientFactoryTests.cs b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeClientFactoryTests.cs new file mode 100644 index 00000000..2b95f031 --- /dev/null +++ b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeClientFactoryTests.cs @@ -0,0 +1,76 @@ +using Elsa.Integrations.AnthropicClaude.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Elsa.Integrations.AnthropicClaude.Tests; + +/// +/// Tests for Claude client factory functionality. +/// +public class ClaudeClientFactoryTests +{ + [Fact] + public void GetClient_ReturnsConfiguredClient_WithApiKey() + { + // Arrange + var httpClientFactory = Substitute.For(); + var logger = Substitute.For>(); + var httpClient = new HttpClient(); + + httpClientFactory.CreateClient("Claude").Returns(httpClient); + + var factory = new ClaudeClientFactory(httpClientFactory, logger); + const string apiKey = "test-api-key"; + + // Act + var client = factory.GetClient(apiKey); + + // Assert + Assert.NotNull(client); + + // Verify the HTTP client was configured with the API key + Assert.Contains(httpClient.DefaultRequestHeaders, h => + h.Key == "x-api-key" && h.Value.Contains(apiKey)); + } + + [Fact] + public void GetClient_CallsHttpClientFactory_WithCorrectName() + { + // Arrange + var httpClientFactory = Substitute.For(); + var logger = Substitute.For>(); + var httpClient = new HttpClient(); + + httpClientFactory.CreateClient("Claude").Returns(httpClient); + + var factory = new ClaudeClientFactory(httpClientFactory, logger); + + // Act + factory.GetClient("test-key"); + + // Assert + httpClientFactory.Received(1).CreateClient("Claude"); + } + + [Fact] + public void GetClient_ReturnsNewInstance_EachTime() + { + // Arrange + var httpClientFactory = Substitute.For(); + var logger = Substitute.For>(); + + httpClientFactory.CreateClient("Claude").Returns(new HttpClient(), new HttpClient()); + + var factory = new ClaudeClientFactory(httpClientFactory, logger); + + // Act + var client1 = factory.GetClient("test-key-1"); + var client2 = factory.GetClient("test-key-2"); + + // Assert + Assert.NotNull(client1); + Assert.NotNull(client2); + // Note: We can't directly compare instances since they wrap different HttpClient instances + httpClientFactory.Received(2).CreateClient("Claude"); + } +} \ No newline at end of file diff --git a/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeModelsTests.cs b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeModelsTests.cs new file mode 100644 index 00000000..7db43954 --- /dev/null +++ b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/ClaudeModelsTests.cs @@ -0,0 +1,143 @@ +using Elsa.Integrations.AnthropicClaude.Models; +using System.Text.Json; + +namespace Elsa.Integrations.AnthropicClaude.Tests; + +/// +/// Tests for Claude model serialization and deserialization. +/// +public class ClaudeModelsTests +{ + [Fact] + public void ClaudeMessage_SerializesToJson_Correctly() + { + // Arrange + var message = new ClaudeMessage + { + Role = "user", + Content = "Hello, Claude!" + }; + + // Act + var json = JsonSerializer.Serialize(message); + + // Assert + Assert.Contains("\"role\":\"user\"", json); + Assert.Contains("\"content\":\"Hello, Claude!\"", json); + } + + [Fact] + public void ClaudeCompletionRequest_SerializesToJson_WithSnakeCaseNaming() + { + // Arrange + var request = new ClaudeCompletionRequest + { + Model = "claude-3-sonnet-20240229", + MaxTokens = 1024, + Messages = new List + { + new() { Role = "user", Content = "Test message" } + }, + Temperature = 0.7 + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + // Act + var json = JsonSerializer.Serialize(request, options); + + // Assert + Assert.Contains("\"max_tokens\":1024", json); + Assert.Contains("\"model\":\"claude-3-sonnet-20240229\"", json); + Assert.Contains("\"temperature\":0.7", json); + Assert.Contains("\"messages\":", json); + } + + [Fact] + public void ClaudeCompletionResponse_DeserializesFromJson_Correctly() + { + // Arrange + var json = """ + { + "id": "msg_123", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "text", + "text": "Hello! How can I help you today?" + } + ], + "model": "claude-3-sonnet-20240229", + "stop_reason": "end_turn", + "usage": { + "input_tokens": 10, + "output_tokens": 20 + } + } + """; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower + }; + + // Act + var response = JsonSerializer.Deserialize(json, options); + + // Assert + Assert.NotNull(response); + Assert.Equal("msg_123", response.Id); + Assert.Equal("message", response.Type); + Assert.Equal("assistant", response.Role); + Assert.Equal("claude-3-sonnet-20240229", response.Model); + Assert.Equal("end_turn", response.StopReason); + + Assert.NotNull(response.Content); + Assert.Single(response.Content); + Assert.Equal("text", response.Content[0].Type); + Assert.Equal("Hello! How can I help you today?", response.Content[0].Text); + + Assert.NotNull(response.Usage); + Assert.Equal(10, response.Usage.InputTokens); + Assert.Equal(20, response.Usage.OutputTokens); + } + + [Fact] + public void ClaudeCompletionRequest_WithNullValues_SerializesCorrectly() + { + // Arrange + var request = new ClaudeCompletionRequest + { + Model = "claude-3-haiku-20240307", + MaxTokens = 512, + Messages = new List + { + new() { Role = "user", Content = "Simple test" } + }, + System = null, + Temperature = null, + StopSequences = null + }; + + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + // Act + var json = JsonSerializer.Serialize(request, options); + + // Assert + Assert.DoesNotContain("\"system\":", json); + Assert.DoesNotContain("\"temperature\":", json); + Assert.DoesNotContain("\"stop_sequences\":", json); + Assert.Contains("\"model\":\"claude-3-haiku-20240307\"", json); + Assert.Contains("\"max_tokens\":512", json); + } +} \ No newline at end of file diff --git a/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/Elsa.Integrations.AnthropicClaude.Tests.csproj b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/Elsa.Integrations.AnthropicClaude.Tests.csproj new file mode 100644 index 00000000..a6f409b9 --- /dev/null +++ b/test/modules/ai/Elsa.Integrations.AnthropicClaude.Tests/Elsa.Integrations.AnthropicClaude.Tests.csproj @@ -0,0 +1,22 @@ +ο»Ώ + + + net8.0 + + + Unit tests for Elsa Anthropic Claude integration. + + false + true + + + + + + + + + + + + \ No newline at end of file