diff --git a/.gitignore b/.gitignore index 4421e310..85010963 100644 --- a/.gitignore +++ b/.gitignore @@ -401,3 +401,7 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +# Local environment files +.env +.env.local +appsettings.local.json diff --git a/Directory.Packages.props b/Directory.Packages.props index 8e83cbf7..bef1e055 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,8 +68,10 @@ + + @@ -103,8 +105,10 @@ + + @@ -206,6 +210,7 @@ + diff --git a/README.md b/README.md index c04cac7c..acc7b720 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Below is the current status of each extension. Icons indicate implementation pro ### 🤖 AI & Automation | Status | Extension | Description | Module Name | Issue | |--------|------------|-------------|-------------|-------| -| 🔲 | **OpenAI** | GPT-based text generation, chatbots | `Elsa.OpenAI` | [Open Issue](https://github.com/elsa-workflows/elsa-extensions/issues/new) | +| ✅ | **OpenAI** | GPT-based text generation, chatbots | `Elsa.OpenAI` | [PR #98](https://github.com/elsa-workflows/elsa-extensions/pull/98) | | 🔲 | **Google AI** | AI-enhanced search, translation | `Elsa.GoogleAI` | [Open Issue](https://github.com/elsa-workflows/elsa-extensions/issues/new) | | 🔲 | **AWS Comprehend** | NLP services for text analysis | `Elsa.AWSComprehend` | [Open Issue](https://github.com/elsa-workflows/elsa-extensions/issues/new) | | 🔲 | **Azure AI** | Vision, speech, language processing | `Elsa.AzureAI` | [Open Issue](https://github.com/elsa-workflows/elsa-extensions/issues/new) | diff --git a/src/modules/llm/Elsa.OpenAI/Activities/Chat/CompleteChat.cs b/src/modules/llm/Elsa.OpenAI/Activities/Chat/CompleteChat.cs new file mode 100644 index 00000000..3a3a4f7e --- /dev/null +++ b/src/modules/llm/Elsa.OpenAI/Activities/Chat/CompleteChat.cs @@ -0,0 +1,94 @@ +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using JetBrains.Annotations; +using OpenAI.Chat; + +namespace Elsa.OpenAI.Activities.Chat; + +/// +/// Completes a chat conversation using OpenAI's Chat API. +/// Supports an optional system message, max token limit, and temperature control. +/// +[Activity( + "Elsa.OpenAI.Chat", + "OpenAI Chat", + "Completes a chat conversation using OpenAI's Chat API.", + DisplayName = "Complete Chat")] +[UsedImplicitly] +public class CompleteChat : OpenAIActivity +{ + /// + /// The user message or prompt to complete. + /// + [Input(Description = "The user message or prompt to complete.")] + public Input Prompt { get; set; } = null!; + + /// + /// Optional system message to provide context or instructions. + /// + [Input(Description = "Optional system message to provide context or instructions.")] + public Input SystemMessage { get; set; } = null!; + + /// + /// The maximum number of tokens to generate. + /// + [Input(Description = "The maximum number of tokens to generate.")] + public Input MaxTokens { get; set; } = null!; + + /// + /// Controls randomness: 0.0 is deterministic, 1.0 is maximum randomness. + /// + [Input(Description = "Controls randomness: 0.0 is deterministic, 1.0 is maximum randomness.")] + public Input Temperature { get; set; } = null!; + + /// + /// The completion result from the chat model. + /// + [Output(Description = "The completion result from the chat model.")] + public Output Result { get; set; } = null!; + + /// + /// The total tokens used in the request. + /// + [Output(Description = "The total tokens used in the request.")] + public Output TotalTokens { get; set; } = null!; + + /// + /// The finish reason for the completion. + /// + [Output(Description = "The finish reason for the completion.")] + public Output FinishReason { get; set; } = null!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var prompt = context.Get(Prompt)!; + var systemMessage = context.Get(SystemMessage); + var maxTokens = context.Get(MaxTokens); + var temperature = context.Get(Temperature); + + var client = GetChatClient(context); + + var messages = new List(); + + if (!string.IsNullOrWhiteSpace(systemMessage)) + messages.Add(ChatMessage.CreateSystemMessage(systemMessage)); + + messages.Add(ChatMessage.CreateUserMessage(prompt)); + + var options = new ChatCompletionOptions(); + + if (maxTokens.HasValue) + options.MaxOutputTokenCount = maxTokens.Value; + + if (temperature.HasValue) + options.Temperature = temperature.Value; + + var completion = await client.CompleteChatAsync(messages, options); + + context.Set(Result, completion.Value.Content[0]?.Text ?? string.Empty); + context.Set(TotalTokens, completion.Value.Usage?.TotalTokenCount); + context.Set(FinishReason, completion.Value.FinishReason.ToString()); + } +} diff --git a/src/modules/llm/Elsa.OpenAI/Activities/OpenAIActivity.cs b/src/modules/llm/Elsa.OpenAI/Activities/OpenAIActivity.cs new file mode 100644 index 00000000..b6669bb0 --- /dev/null +++ b/src/modules/llm/Elsa.OpenAI/Activities/OpenAIActivity.cs @@ -0,0 +1,88 @@ +using Elsa.OpenAI.Services; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using OpenAI; +using OpenAI.Audio; +using OpenAI.Chat; +using OpenAI.Embeddings; +using OpenAI.Images; +using OpenAI.Moderations; + +namespace Elsa.OpenAI.Activities; + +/// +/// Abstract base class for all OpenAI workflow activities. +/// Provides shared inputs for API key and model, plus convenience methods +/// for resolving typed OpenAI clients from the . +/// +public abstract class OpenAIActivity : Activity +{ + /// + /// The OpenAI API key used to authenticate requests. + /// + [Input(Description = "The OpenAI API key.")] + public Input ApiKey { get; set; } = null!; + + /// + /// The OpenAI model identifier (e.g., "gpt-4o", "dall-e-3"). + /// + [Input(Description = "The OpenAI model to use.")] + public Input Model { get; set; } = null!; + + /// + /// Resolves the from the current execution context. + /// + /// The activity execution context. + /// The registered instance. + protected static OpenAIClientFactory GetClientFactory(ActivityExecutionContext context) => + context.GetRequiredService(); + + /// + /// Gets a base using the configured API key. + /// + /// The activity execution context. + /// An instance. + protected OpenAIClient GetClient(ActivityExecutionContext context) => + GetClientFactory(context).GetClient(context.Get(ApiKey)!); + + /// + /// Gets a using the configured model and API key. + /// + /// The activity execution context. + /// A instance. + protected ChatClient GetChatClient(ActivityExecutionContext context) => + GetClientFactory(context).GetChatClient(context.Get(Model)!, context.Get(ApiKey)!); + + /// + /// Gets an using the configured model and API key. + /// + /// The activity execution context. + /// An instance. + protected ImageClient GetImageClient(ActivityExecutionContext context) => + GetClientFactory(context).GetImageClient(context.Get(Model)!, context.Get(ApiKey)!); + + /// + /// Gets an using the configured model and API key. + /// + /// The activity execution context. + /// An instance. + protected AudioClient GetAudioClient(ActivityExecutionContext context) => + GetClientFactory(context).GetAudioClient(context.Get(Model)!, context.Get(ApiKey)!); + + /// + /// Gets an using the configured model and API key. + /// + /// The activity execution context. + /// An instance. + protected EmbeddingClient GetEmbeddingClient(ActivityExecutionContext context) => + GetClientFactory(context).GetEmbeddingClient(context.Get(Model)!, context.Get(ApiKey)!); + + /// + /// Gets a using the configured model and API key. + /// + /// The activity execution context. + /// A instance. + protected ModerationClient GetModerationClient(ActivityExecutionContext context) => + GetClientFactory(context).GetModerationClient(context.Get(Model)!, context.Get(ApiKey)!); +} diff --git a/src/modules/llm/Elsa.OpenAI/Elsa.OpenAI.csproj b/src/modules/llm/Elsa.OpenAI/Elsa.OpenAI.csproj new file mode 100644 index 00000000..48ed2a8d --- /dev/null +++ b/src/modules/llm/Elsa.OpenAI/Elsa.OpenAI.csproj @@ -0,0 +1,13 @@ + + + + Provides OpenAI integration activities for Elsa Workflows + elsa extension module openai ai chat completion + + + + + + + + diff --git a/src/modules/llm/Elsa.OpenAI/Features/OpenAIFeature.cs b/src/modules/llm/Elsa.OpenAI/Features/OpenAIFeature.cs new file mode 100644 index 00000000..ccc2220d --- /dev/null +++ b/src/modules/llm/Elsa.OpenAI/Features/OpenAIFeature.cs @@ -0,0 +1,19 @@ +using Elsa.Features.Abstractions; +using Elsa.Features.Services; +using Elsa.OpenAI.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.OpenAI.Features; + +/// +/// Represents a feature for setting up OpenAI integration within the Elsa framework. +/// +public class OpenAIFeature(IModule module) : FeatureBase(module) +{ + /// + /// Applies the feature to the specified service collection. + /// + public override void Apply() => + Services + .AddSingleton(); +} \ No newline at end of file diff --git a/src/modules/llm/Elsa.OpenAI/FodyWeavers.xml b/src/modules/llm/Elsa.OpenAI/FodyWeavers.xml new file mode 100644 index 00000000..e7060694 --- /dev/null +++ b/src/modules/llm/Elsa.OpenAI/FodyWeavers.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/modules/llm/Elsa.OpenAI/README.md b/src/modules/llm/Elsa.OpenAI/README.md new file mode 100644 index 00000000..a124f786 --- /dev/null +++ b/src/modules/llm/Elsa.OpenAI/README.md @@ -0,0 +1,319 @@ +# Elsa.OpenAI + +OpenAI integration for Elsa Workflows, enabling GPT-based text generation and chatbot functionality in your workflows. + +## 🚀 Getting Started + +### Installation +```bash +dotnet add package Elsa.OpenAI +``` + +### Configuration +```csharp +services.AddElsa(elsa => +{ + elsa.UseOpenAIFeature(); +}); +``` + +### API Key Setup +Set your OpenAI API key using one of these methods: + +**User Secrets (Development):** +```bash +dotnet user-secrets set "OpenAI:ApiKey" "your-api-key-here" +``` + +**Environment Variable:** +```bash +export OPENAI_API_KEY="your-api-key-here" +``` + +## 📋 Activities + +### Complete Chat +Generates text responses using OpenAI's chat models. + +**Inputs:** +- `Prompt` (string) - The user message or question +- `SystemMessage` (string, optional) - Context or instructions for the AI +- `Model` (string) - OpenAI model (e.g., "gpt-3.5-turbo", "gpt-4") +- `MaxTokens` (int, optional) - Maximum response length +- `Temperature` (float, optional) - Response creativity (0.0-1.0) +- `ApiKey` (string) - OpenAI API key + +**Outputs:** +- `Result` (string) - The AI-generated response +- `TotalTokens` (int) - Number of tokens used +- `FinishReason` (string) - How the completion ended + +## 💡 Use Cases + +### Customer Support Chatbot +A workflow that processes support tickets and generates AI responses: + +```csharp +public class CustomerSupportWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var customerQuery = builder.WithVariable(); + var aiResponse = builder.WithVariable(); + + builder.Root = new Sequence + { + Activities = + { + // Trigger on incoming support ticket + new HttpEndpoint + { + Path = new("/support/chat"), + SupportedMethods = new([HttpMethods.Post]) + }, + // Extract customer query from request + new SetVariable + { + Variable = customerQuery, + Value = new(context => context.GetInput("query")) + }, + // Generate AI response + new CompleteChat + { + SystemMessage = new("You are a helpful customer support agent. Be polite, professional, and provide clear solutions."), + Prompt = customerQuery, + Model = new("gpt-4"), + Temperature = new(0.3f), + Result = new(aiResponse), + ApiKey = new("your-api-key-here") + }, + // Return response to customer + new WriteHttpResponse + { + Content = new(context => new { response = aiResponse.Get(context) }) + } + } + }; + } +} +``` + +### Content Generation Pipeline +A workflow that generates marketing content based on product data: + +```csharp +public class ContentGenerationWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var productInfo = builder.WithVariable(); + var marketingCopy = builder.WithVariable(); + var socialPost = builder.WithVariable(); + + builder.Root = new Sequence + { + Activities = + { + // Read product information from database + new SetVariable + { + Variable = productInfo, + Value = new(context => GetProductDetails(context.GetInput("productId"))) + }, + // Generate marketing copy + new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("Generate compelling marketing copy for our product. Focus on benefits and create urgency."), + Prompt = new(context => $"Product: {productInfo.Get(context)}\nTarget audience: Tech-savvy professionals"), + Model = new("gpt-3.5-turbo"), + MaxTokens = new(500), + Temperature = new(0.7f), + Result = new(marketingCopy) + }, + // Generate social media version + new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("Create a concise, engaging social media post with hashtags."), + Prompt = new(context => $"Create a social post based on this copy: {marketingCopy.Get(context)}"), + Model = new("gpt-3.5-turbo"), + MaxTokens = new(280), + Temperature = new(0.8f), + Result = new(socialPost) + }, + // Save content to CMS + new WriteLine(context => $"Marketing Copy: {marketingCopy.Get(context)}"), + new WriteLine(context => $"Social Post: {socialPost.Get(context)}") + } + }; + } + + private string GetProductDetails(int productId) => + $"Smart fitness tracker with heart rate monitoring, GPS, and 7-day battery life. Price: $199"; +} +``` + +### Intelligent Document Processing +A workflow that analyzes uploaded documents and extracts key information: + +```csharp +public class DocumentAnalysisWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var documentText = builder.WithVariable(); + var extractedData = builder.WithVariable(); + var classification = builder.WithVariable(); + + builder.Root = new Sequence + { + Activities = + { + // File upload trigger + new HttpEndpoint + { + Path = new("/documents/analyze"), + SupportedMethods = new([HttpMethods.Post]) + }, + // Extract text from document + new SetVariable + { + Variable = documentText, + Value = new(context => ExtractTextFromDocument(context.GetInput("file"))) + }, + // Classify document type + new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("Classify the document type. Respond with only: INVOICE, CONTRACT, RESUME, or OTHER."), + Prompt = new(context => $"Document content: {documentText.Get(context)?.Substring(0, 1000)}"), + Model = new("gpt-3.5-turbo"), + MaxTokens = new(10), + Temperature = new(0.1f), + Result = new(classification) + }, + // Extract structured data based on type + new If + { + Condition = new(context => classification.Get(context) == "INVOICE"), + Then = new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("Extract invoice details as JSON: {amount, date, vendor, invoiceNumber}"), + Prompt = documentText, + Model = new("gpt-4"), + Temperature = new(0.2f), + Result = new(extractedData) + }, + Else = new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("Summarize the key points from this document in bullet format."), + Prompt = documentText, + Model = new("gpt-3.5-turbo"), + Temperature = new(0.3f), + Result = new(extractedData) + } + }, + // Return analysis results + new WriteHttpResponse + { + Content = new(context => new + { + documentType = classification.Get(context), + extractedData = extractedData.Get(context), + processingTime = DateTime.UtcNow + }) + } + } + }; + } + + private string ExtractTextFromDocument(byte[] fileData) => + "Sample extracted text from document..."; +} +``` + +### Multi-Step Code Review Assistant +A workflow that performs comprehensive code analysis: + +```csharp +public class CodeReviewWorkflow : WorkflowBase +{ + protected override void Build(IWorkflowBuilder builder) + { + var codeToReview = builder.WithVariable(); + var securityAnalysis = builder.WithVariable(); + var performanceReview = builder.WithVariable(); + var finalSummary = builder.WithVariable(); + + builder.Root = new Sequence + { + Activities = + { + new SetVariable + { + Variable = codeToReview, + Value = new(context => context.GetInput("code")) + }, + // Parallel analysis + new Fork + { + JoinMode = ForkJoinMode.WaitAll, + Branches = + { + // Security analysis + new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("You are a security expert. Analyze code for vulnerabilities, injection risks, and security best practices."), + Prompt = codeToReview, + Model = new("gpt-4"), + Temperature = new(0.2f), + Result = new(securityAnalysis) + }, + // Performance analysis + new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("You are a performance expert. Review code for efficiency, scalability, and optimization opportunities."), + Prompt = codeToReview, + Model = new("gpt-4"), + Temperature = new(0.2f), + Result = new(performanceReview) + } + } + }, + // Generate comprehensive summary + new CompleteChat + { + ApiKey = new("your-api-key-here"), + SystemMessage = new("Create a comprehensive code review summary combining security and performance feedback. Provide actionable recommendations."), + Prompt = new(context => + $"Code:\n{codeToReview.Get(context)}\n\n" + + $"Security Analysis:\n{securityAnalysis.Get(context)}\n\n" + + $"Performance Analysis:\n{performanceReview.Get(context)}"), + Model = new("gpt-4"), + Temperature = new(0.3f), + Result = new(finalSummary) + }, + new WriteLine(context => $"Code Review Complete:\n{finalSummary.Get(context)}") + } + }; + } +} +``` + +## 🔧 Configuration Options + +- **Model Selection**: Choose from GPT-3.5, GPT-4, or other available models +- **Temperature Control**: Adjust response creativity and randomness +- **Token Limits**: Control response length and API costs +- **System Messages**: Provide context and role-based instructions + +## 🔐 Security + +- API keys are never logged or exposed in workflow definitions +- Use User Secrets for development environments +- Use secure environment variables or key vaults for production diff --git a/src/modules/llm/Elsa.OpenAI/Services/OpenAIClientFactory.cs b/src/modules/llm/Elsa.OpenAI/Services/OpenAIClientFactory.cs new file mode 100644 index 00000000..f64eca98 --- /dev/null +++ b/src/modules/llm/Elsa.OpenAI/Services/OpenAIClientFactory.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using OpenAI; +using OpenAI.Audio; +using OpenAI.Chat; +using OpenAI.Embeddings; +using OpenAI.Images; +using OpenAI.Moderations; + +namespace Elsa.OpenAI.Services; + +/// +/// Thread-safe factory for creating and caching OpenAI API clients. +/// Clients are cached by API key to avoid unnecessary allocations. +/// +public sealed class OpenAIClientFactory +{ + private readonly ConcurrentDictionary _clients = new(); + + /// + /// Gets or creates a cached for the specified API key. + /// + /// The OpenAI API key. + /// A cached instance. + /// Thrown when is null or whitespace. + public OpenAIClient GetClient(string apiKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(apiKey); + return _clients.GetOrAdd(apiKey, static key => new OpenAIClient(key)); + } + + /// + /// Gets a for the specified model and API key. + /// + /// The model identifier (e.g., "gpt-4o"). + /// The OpenAI API key. + /// A instance. + /// Thrown when or is null or whitespace. + public ChatClient GetChatClient(string model, string apiKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(model); + return GetClient(apiKey).GetChatClient(model); + } + + /// + /// Gets an for the specified model and API key. + /// + /// The model identifier (e.g., "dall-e-3"). + /// The OpenAI API key. + /// An instance. + /// Thrown when or is null or whitespace. + public ImageClient GetImageClient(string model, string apiKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(model); + return GetClient(apiKey).GetImageClient(model); + } + + /// + /// Gets an for the specified model and API key. + /// + /// The model identifier (e.g., "whisper-1"). + /// The OpenAI API key. + /// An instance. + /// Thrown when or is null or whitespace. + public AudioClient GetAudioClient(string model, string apiKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(model); + return GetClient(apiKey).GetAudioClient(model); + } + + /// + /// Gets an for the specified model and API key. + /// + /// The model identifier (e.g., "text-embedding-3-small"). + /// The OpenAI API key. + /// An instance. + /// Thrown when or is null or whitespace. + public EmbeddingClient GetEmbeddingClient(string model, string apiKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(model); + return GetClient(apiKey).GetEmbeddingClient(model); + } + + /// + /// Gets a for the specified model and API key. + /// + /// The model identifier (e.g., "omni-moderation-latest"). + /// The OpenAI API key. + /// A instance. + /// Thrown when or is null or whitespace. + public ModerationClient GetModerationClient(string model, string apiKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(model); + return GetClient(apiKey).GetModerationClient(model); + } +} diff --git a/test/modules/llm/Elsa.OpenAI.Tests/Activities/Chat/CompleteChatTests.cs b/test/modules/llm/Elsa.OpenAI.Tests/Activities/Chat/CompleteChatTests.cs new file mode 100644 index 00000000..f43aea9b --- /dev/null +++ b/test/modules/llm/Elsa.OpenAI.Tests/Activities/Chat/CompleteChatTests.cs @@ -0,0 +1,253 @@ +using Elsa.OpenAI.Activities; +using Elsa.OpenAI.Activities.Chat; +using Elsa.OpenAI.Services; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Moq; +using OpenAI; +using OpenAI.Chat; + +namespace Elsa.OpenAI.Tests.Activities.Chat; + +/// +/// Contains tests for the activity. +/// +public class CompleteChatTests +{ + /// + /// Test implementation of CompleteChat to expose protected methods. + /// + private class TestableCompleteChat : CompleteChat + { + public new async ValueTask ExecuteAsync(ActivityExecutionContext context) => await base.ExecuteAsync(context); + } + [Fact] + public void Constructor_CreatesInstance() + { + // Act + var activity = new CompleteChat(); + + // Assert + Assert.NotNull(activity); + } + + [Fact] + public void CompleteChat_HasCorrectInputProperties() + { + // Arrange & Act - Test that properties exist and have correct types + var activityType = typeof(CompleteChat); + var promptProperty = activityType.GetProperty(nameof(CompleteChat.Prompt)); + var systemMessageProperty = activityType.GetProperty(nameof(CompleteChat.SystemMessage)); + var maxTokensProperty = activityType.GetProperty(nameof(CompleteChat.MaxTokens)); + var temperatureProperty = activityType.GetProperty(nameof(CompleteChat.Temperature)); + var apiKeyProperty = activityType.GetProperty(nameof(CompleteChat.ApiKey)); + var modelProperty = activityType.GetProperty(nameof(CompleteChat.Model)); + + // Assert + Assert.NotNull(promptProperty); + Assert.NotNull(systemMessageProperty); + Assert.NotNull(maxTokensProperty); + Assert.NotNull(temperatureProperty); + Assert.NotNull(apiKeyProperty); + Assert.NotNull(modelProperty); + + // Verify property types + Assert.Equal(typeof(Input), promptProperty.PropertyType); + Assert.Equal(typeof(Input), systemMessageProperty.PropertyType); + Assert.Equal(typeof(Input), maxTokensProperty.PropertyType); + Assert.Equal(typeof(Input), temperatureProperty.PropertyType); + Assert.Equal(typeof(Input), apiKeyProperty.PropertyType); + Assert.Equal(typeof(Input), modelProperty.PropertyType); + } + + [Fact] + public void CompleteChat_HasCorrectOutputProperties() + { + // Arrange & Act - Test that properties exist and have correct types + var activityType = typeof(CompleteChat); + var resultProperty = activityType.GetProperty(nameof(CompleteChat.Result)); + var totalTokensProperty = activityType.GetProperty(nameof(CompleteChat.TotalTokens)); + var finishReasonProperty = activityType.GetProperty(nameof(CompleteChat.FinishReason)); + + // Assert + Assert.NotNull(resultProperty); + Assert.NotNull(totalTokensProperty); + Assert.NotNull(finishReasonProperty); + + // Verify property types + Assert.Equal(typeof(Output), resultProperty.PropertyType); + Assert.Equal(typeof(Output), totalTokensProperty.PropertyType); + Assert.Equal(typeof(Output), finishReasonProperty.PropertyType); + } + + [Fact] + public void CompleteChat_HasActivityAttribute() + { + // Arrange + var activityType = typeof(CompleteChat); + + // Act + var activityAttribute = activityType.GetCustomAttributes(typeof(ActivityAttribute), false).FirstOrDefault() as ActivityAttribute; + + // Assert + Assert.NotNull(activityAttribute); + Assert.Equal("Elsa.OpenAI.Chat", activityAttribute.Namespace); + Assert.Equal("OpenAI Chat", activityAttribute.Category); + Assert.Equal("Complete Chat", activityAttribute.DisplayName); + Assert.Contains("chat conversation", activityAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Prompt_HasInputAttribute() + { + // Arrange + var property = typeof(CompleteChat).GetProperty(nameof(CompleteChat.Prompt)); + + // Act + var inputAttribute = property?.GetCustomAttributes(typeof(InputAttribute), false).FirstOrDefault() as InputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(inputAttribute); + Assert.Contains("prompt", inputAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void SystemMessage_HasInputAttribute() + { + // Arrange + var property = typeof(CompleteChat).GetProperty(nameof(CompleteChat.SystemMessage)); + + // Act + var inputAttribute = property?.GetCustomAttributes(typeof(InputAttribute), false).FirstOrDefault() as InputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(inputAttribute); + Assert.Contains("system message", inputAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void MaxTokens_HasInputAttribute() + { + // Arrange + var property = typeof(CompleteChat).GetProperty(nameof(CompleteChat.MaxTokens)); + + // Act + var inputAttribute = property?.GetCustomAttributes(typeof(InputAttribute), false).FirstOrDefault() as InputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(inputAttribute); + Assert.Contains("tokens", inputAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Temperature_HasInputAttribute() + { + // Arrange + var property = typeof(CompleteChat).GetProperty(nameof(CompleteChat.Temperature)); + + // Act + var inputAttribute = property?.GetCustomAttributes(typeof(InputAttribute), false).FirstOrDefault() as InputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(inputAttribute); + Assert.Contains("randomness", inputAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Result_HasOutputAttribute() + { + // Arrange + var property = typeof(CompleteChat).GetProperty(nameof(CompleteChat.Result)); + + // Act + var outputAttribute = property?.GetCustomAttributes(typeof(OutputAttribute), false).FirstOrDefault() as OutputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(outputAttribute); + Assert.Contains("result", outputAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void TotalTokens_HasOutputAttribute() + { + // Arrange + var property = typeof(CompleteChat).GetProperty(nameof(CompleteChat.TotalTokens)); + + // Act + var outputAttribute = property?.GetCustomAttributes(typeof(OutputAttribute), false).FirstOrDefault() as OutputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(outputAttribute); + Assert.Contains("tokens", outputAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void FinishReason_HasOutputAttribute() + { + // Arrange + var property = typeof(CompleteChat).GetProperty(nameof(CompleteChat.FinishReason)); + + // Act + var outputAttribute = property?.GetCustomAttributes(typeof(OutputAttribute), false).FirstOrDefault() as OutputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(outputAttribute); + Assert.Contains("finish reason", outputAttribute.Description, StringComparison.OrdinalIgnoreCase); + } + + + [Fact] + public void CompleteChat_HasCorrectAttributes() + { + // Arrange + var activityType = typeof(CompleteChat); + + // Act - Check for Activity attribute (which we know exists) + var activityAttribute = activityType.GetCustomAttributes(typeof(ActivityAttribute), false).FirstOrDefault(); + var allAttributes = activityType.GetCustomAttributes(false); + + // Assert + Assert.NotNull(activityAttribute); + Assert.True(allAttributes.Length > 0, "CompleteChat should have at least one attribute"); + } + + [Fact] + public void ExecuteAsync_MethodExists_AndIsProtected() + { + // Test that ExecuteAsync method exists and has the correct signature + var activityType = typeof(CompleteChat); + var executeMethod = activityType.GetMethod("ExecuteAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.NotNull(executeMethod); + Assert.Equal(typeof(ValueTask), executeMethod.ReturnType); + Assert.Single(executeMethod.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), executeMethod.GetParameters()[0].ParameterType); + } + + [Fact] + public void CompleteChat_InheritsFromOpenAIActivity() + { + // Verify inheritance structure + Assert.True(typeof(OpenAIActivity).IsAssignableFrom(typeof(CompleteChat))); + } + + [Fact] + public void CompleteChat_UsesGetChatClientMethod() + { + // This test verifies that CompleteChat has access to the GetChatClient method from base class + + var baseType = typeof(OpenAIActivity); + var getChatClientMethod = baseType.GetMethod("GetChatClient", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + Assert.NotNull(getChatClientMethod); + Assert.Equal(typeof(ChatClient), getChatClientMethod.ReturnType); + } +} diff --git a/test/modules/llm/Elsa.OpenAI.Tests/Activities/OpenAIActivityTests.cs b/test/modules/llm/Elsa.OpenAI.Tests/Activities/OpenAIActivityTests.cs new file mode 100644 index 00000000..3172ae58 --- /dev/null +++ b/test/modules/llm/Elsa.OpenAI.Tests/Activities/OpenAIActivityTests.cs @@ -0,0 +1,174 @@ +using Elsa.OpenAI.Activities; +using Elsa.OpenAI.Services; +using Elsa.Workflows; +using Elsa.Workflows.Attributes; +using Elsa.Workflows.Models; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using OpenAI; +using OpenAI.Audio; +using OpenAI.Chat; +using OpenAI.Embeddings; +using OpenAI.Images; +using OpenAI.Moderations; + +namespace Elsa.OpenAI.Tests.Activities; + +/// +/// Unit tests for the OpenAIActivity base class. +/// +public class OpenAIActivityTests +{ + /// + /// Test implementation of OpenAIActivity to test protected methods. + /// + private class TestOpenAIActivity : OpenAIActivity + { + protected override ValueTask ExecuteAsync(ActivityExecutionContext context) => ValueTask.CompletedTask; + + // Expose protected methods for testing + public static new OpenAIClientFactory GetClientFactory(ActivityExecutionContext context) => OpenAIActivity.GetClientFactory(context); + public new OpenAIClient GetClient(ActivityExecutionContext context) => base.GetClient(context); + public new ChatClient GetChatClient(ActivityExecutionContext context) => base.GetChatClient(context); + public new ImageClient GetImageClient(ActivityExecutionContext context) => base.GetImageClient(context); + public new AudioClient GetAudioClient(ActivityExecutionContext context) => base.GetAudioClient(context); + public new EmbeddingClient GetEmbeddingClient(ActivityExecutionContext context) => base.GetEmbeddingClient(context); + public new ModerationClient GetModerationClient(ActivityExecutionContext context) => base.GetModerationClient(context); + } + + + [Fact] + public void OpenAIActivity_IsAbstractClass() + { + // Arrange & Act + var activityType = typeof(OpenAIActivity); + + // Assert + Assert.True(activityType.IsAbstract); + } + + [Fact] + public void OpenAIActivity_HasApiKeyProperty() + { + // Arrange + var property = typeof(OpenAIActivity).GetProperty(nameof(OpenAIActivity.ApiKey)); + + // Act + var inputAttribute = property?.GetCustomAttributes(typeof(InputAttribute), false).FirstOrDefault() as InputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(inputAttribute); + Assert.Contains("API key", inputAttribute.Description); + } + + [Fact] + public void OpenAIActivity_HasModelProperty() + { + // Arrange + var property = typeof(OpenAIActivity).GetProperty(nameof(OpenAIActivity.Model)); + + // Act + var inputAttribute = property?.GetCustomAttributes(typeof(InputAttribute), false).FirstOrDefault() as InputAttribute; + + // Assert + Assert.NotNull(property); + Assert.NotNull(inputAttribute); + Assert.Contains("model", inputAttribute.Description); + } + + [Fact] + public void OpenAIActivity_InheritsFromActivity() + { + // Assert + Assert.True(typeof(Elsa.Workflows.Activity).IsAssignableFrom(typeof(OpenAIActivity))); + } + + [Fact] + public void GetClientFactory_Integration_Test() + { + // Since GetClientFactory uses GetRequiredService which is non-virtual, + // we test that the method exists and has correct signature + + var methodInfo = typeof(TestOpenAIActivity).GetMethod("GetClientFactory"); + + Assert.NotNull(methodInfo); + Assert.Equal(typeof(OpenAIClientFactory), methodInfo.ReturnType); + Assert.Single(methodInfo.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), methodInfo.GetParameters()[0].ParameterType); + } + + [Fact] + public void GetClient_Integration_Test() + { + // Test that the method exists and has correct signature + var methodInfo = typeof(TestOpenAIActivity).GetMethod("GetClient"); + + Assert.NotNull(methodInfo); + Assert.Equal(typeof(OpenAIClient), methodInfo.ReturnType); + Assert.Single(methodInfo.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), methodInfo.GetParameters()[0].ParameterType); + } + + [Fact] + public void GetChatClient_Integration_Test() + { + // Test that the method exists and has correct signature + var methodInfo = typeof(TestOpenAIActivity).GetMethod("GetChatClient"); + + Assert.NotNull(methodInfo); + Assert.Equal(typeof(ChatClient), methodInfo.ReturnType); + Assert.Single(methodInfo.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), methodInfo.GetParameters()[0].ParameterType); + } + + [Fact] + public void GetImageClient_Integration_Test() + { + // Test that the method exists and has correct signature + var methodInfo = typeof(TestOpenAIActivity).GetMethod("GetImageClient"); + + Assert.NotNull(methodInfo); + Assert.Equal(typeof(ImageClient), methodInfo.ReturnType); + Assert.Single(methodInfo.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), methodInfo.GetParameters()[0].ParameterType); + } + + [Fact] + public void GetAudioClient_Integration_Test() + { + // Test that the method exists and has correct signature + var methodInfo = typeof(TestOpenAIActivity).GetMethod("GetAudioClient"); + + Assert.NotNull(methodInfo); + Assert.Equal(typeof(AudioClient), methodInfo.ReturnType); + Assert.Single(methodInfo.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), methodInfo.GetParameters()[0].ParameterType); + } + + [Fact] + public void GetEmbeddingClient_Integration_Test() + { + // Test that the method exists and has correct signature + + var methodInfo = typeof(TestOpenAIActivity).GetMethod("GetEmbeddingClient"); + + Assert.NotNull(methodInfo); + Assert.Equal(typeof(EmbeddingClient), methodInfo.ReturnType); + Assert.Single(methodInfo.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), methodInfo.GetParameters()[0].ParameterType); + } + + [Fact] + public void GetModerationClient_Integration_Test() + { + // Test that the method exists and has correct signature + + var methodInfo = typeof(TestOpenAIActivity).GetMethod("GetModerationClient"); + + Assert.NotNull(methodInfo); + Assert.Equal(typeof(ModerationClient), methodInfo.ReturnType); + Assert.Single(methodInfo.GetParameters()); + Assert.Equal(typeof(ActivityExecutionContext), methodInfo.GetParameters()[0].ParameterType); + } +} diff --git a/test/modules/llm/Elsa.OpenAI.Tests/Elsa.OpenAI.Tests.csproj b/test/modules/llm/Elsa.OpenAI.Tests/Elsa.OpenAI.Tests.csproj new file mode 100644 index 00000000..efcef112 --- /dev/null +++ b/test/modules/llm/Elsa.OpenAI.Tests/Elsa.OpenAI.Tests.csproj @@ -0,0 +1,16 @@ + + + + 84ce6d48-b563-4170-9ee8-f62cd416f906 + + + + + + + + + + + + diff --git a/test/modules/llm/Elsa.OpenAI.Tests/Features/OpenAIFeatureTests.cs b/test/modules/llm/Elsa.OpenAI.Tests/Features/OpenAIFeatureTests.cs new file mode 100644 index 00000000..998ad686 --- /dev/null +++ b/test/modules/llm/Elsa.OpenAI.Tests/Features/OpenAIFeatureTests.cs @@ -0,0 +1,118 @@ +using Elsa.Features.Services; +using Elsa.OpenAI.Features; +using Elsa.OpenAI.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace Elsa.OpenAI.Tests.Features; + +/// +/// Unit tests for the OpenAIFeature class. +/// +public class OpenAIFeatureTests +{ + [Fact] + public void Constructor_WithValidModule_CreatesInstance() + { + // Arrange + var mockModule = new Mock(); + + // Act + var feature = new OpenAIFeature(mockModule.Object); + + // Assert + Assert.NotNull(feature); + } + + [Fact] + public void Constructor_WithNullModule_CreatesInstance() + { + // Arrange & Act + var feature = new OpenAIFeature(null!); + + // Assert + Assert.NotNull(feature); + } + + [Fact] + public void Apply_RegistersOpenAIClientFactory() + { + // Arrange + var services = new ServiceCollection(); + var mockModule = new Mock(); + mockModule.Setup(m => m.Services).Returns(services); + var feature = new OpenAIFeature(mockModule.Object); + + // Act + feature.Apply(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetService(); + Assert.NotNull(factory); + } + + [Fact] + public void Apply_RegistersOpenAIClientFactoryAsSingleton() + { + // Arrange + var services = new ServiceCollection(); + var mockModule = new Mock(); + mockModule.Setup(m => m.Services).Returns(services); + var feature = new OpenAIFeature(mockModule.Object); + + // Act + feature.Apply(); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var factory1 = serviceProvider.GetService(); + var factory2 = serviceProvider.GetService(); + + Assert.NotNull(factory1); + Assert.NotNull(factory2); + Assert.Same(factory1, factory2); + } + + [Fact] + public void Apply_CanBeCalledMultipleTimes() + { + // Arrange + var services = new ServiceCollection(); + var mockModule = new Mock(); + mockModule.Setup(m => m.Services).Returns(services); + var feature = new OpenAIFeature(mockModule.Object); + + // Act + feature.Apply(); + feature.Apply(); // Should not throw + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetService(); + Assert.NotNull(factory); + } + + [Fact] + public void Apply_WithExistingServices_DoesNotDuplicate() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + var mockModule = new Mock(); + mockModule.Setup(m => m.Services).Returns(services); + var feature = new OpenAIFeature(mockModule.Object); + + // Act + feature.Apply(); + + // Assert + var serviceDescriptors = services.Where(s => s.ServiceType == typeof(OpenAIClientFactory)).ToList(); + Assert.Equal(2, serviceDescriptors.Count); // One from manual add, one from feature + + // But when resolved, it should still work correctly + var serviceProvider = services.BuildServiceProvider(); + var factory = serviceProvider.GetService(); + Assert.NotNull(factory); + } +} diff --git a/test/modules/llm/Elsa.OpenAI.Tests/GlobalUsings.cs b/test/modules/llm/Elsa.OpenAI.Tests/GlobalUsings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/test/modules/llm/Elsa.OpenAI.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/test/modules/llm/Elsa.OpenAI.Tests/Integration/OpenAIIntegrationTests.cs b/test/modules/llm/Elsa.OpenAI.Tests/Integration/OpenAIIntegrationTests.cs new file mode 100644 index 00000000..58328693 --- /dev/null +++ b/test/modules/llm/Elsa.OpenAI.Tests/Integration/OpenAIIntegrationTests.cs @@ -0,0 +1,136 @@ +using Elsa.OpenAI.Activities.Chat; +using Elsa.OpenAI.Services; +using Microsoft.Extensions.Configuration; + +namespace Elsa.OpenAI.Tests.Integration; + +/// +/// Integration tests that can make real API calls to OpenAI when API key is available. +/// +public class OpenAIIntegrationTests +{ + private readonly string? _apiKey; + private readonly bool _hasApiKey; + + public OpenAIIntegrationTests() + { + // Build configuration to access user secrets and environment variables + var configuration = new ConfigurationBuilder() + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + _apiKey = configuration["OpenAI:ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY"); + _hasApiKey = !string.IsNullOrEmpty(_apiKey); + } + + [Fact] + public void ApiKey_Configuration_IsAccessible() + { + // This test always passes - it just reports whether API key is available + if (_hasApiKey) + { + Assert.True(_apiKey!.Length > 10, "API key should have reasonable length"); + } + else + { + // Skip test but don't fail - just document how to set it up + Assert.True(true); // Always pass, but log the setup instructions + } + } + + [Fact] + public void OpenAIClientFactory_CanCreateDifferentClients() + { + // Arrange + var factory = new OpenAIClientFactory(); + var testApiKey = _apiKey ?? "test-key"; + + // Act & Assert + var chatClient = factory.GetChatClient("gpt-3.5-turbo", testApiKey); + var imageClient = factory.GetImageClient("dall-e-3", testApiKey); + var audioClient = factory.GetAudioClient("whisper-1", testApiKey); + var embeddingClient = factory.GetEmbeddingClient("text-embedding-3-small", testApiKey); + var moderationClient = factory.GetModerationClient("omni-moderation-latest", testApiKey); + + Assert.NotNull(chatClient); + Assert.NotNull(imageClient); + Assert.NotNull(audioClient); + Assert.NotNull(embeddingClient); + Assert.NotNull(moderationClient); + } + + [Fact] + public void OpenAIClientFactory_CachesClientInstances() + { + // Arrange + var factory = new OpenAIClientFactory(); + var testApiKey = _apiKey ?? "test-key"; + + // Act + var client1 = factory.GetClient(testApiKey); + var client2 = factory.GetClient(testApiKey); + var client3 = factory.GetClient("different-key"); + + // Assert + Assert.Same(client1, client2); // Same key should return same instance + Assert.NotSame(client1, client3); // Different keys should return different instances + } + + [Fact] + public async Task RealApiCall_ChatCompletion_ReturnsValidResponse() + { + // Skip test if no API key available + if (!_hasApiKey) + { + Assert.True(true); // Pass but skip - API key not configured + return; + } + + // Arrange + var factory = new OpenAIClientFactory(); + var client = factory.GetChatClient("gpt-3.5-turbo", _apiKey!); + + try + { + // Act + var result = await client.CompleteChatAsync("Say 'Hello from Elsa OpenAI unit tests!'"); + var completion = result.Value; + + // Assert + Assert.NotNull(completion); + Assert.NotNull(completion.Content); + Assert.True(completion.Content.Count > 0); + Assert.False(string.IsNullOrEmpty(completion.Content[0].Text)); + Assert.True(completion.Usage?.TotalTokenCount > 0); + + // Verify the response contains our expected text + var responseText = completion.Content[0].Text; + Assert.Contains("Hello from Elsa OpenAI unit tests", responseText); + } + catch (Exception ex) + { + // If API call fails, provide helpful error message + Assert.Fail($"OpenAI API call failed: {ex.Message}. Check your API key and network connection."); + } + } + + [Fact] + public void CompleteChat_Activity_HasCorrectStructure() + { + // Arrange & Act + + var activityType = typeof(CompleteChat); + + // Assert - Check that all required properties exist + Assert.NotNull(activityType.GetProperty("Prompt")); + Assert.NotNull(activityType.GetProperty("SystemMessage")); + Assert.NotNull(activityType.GetProperty("MaxTokens")); + Assert.NotNull(activityType.GetProperty("Temperature")); + Assert.NotNull(activityType.GetProperty("ApiKey")); + Assert.NotNull(activityType.GetProperty("Model")); + Assert.NotNull(activityType.GetProperty("Result")); + Assert.NotNull(activityType.GetProperty("TotalTokens")); + Assert.NotNull(activityType.GetProperty("FinishReason")); + } +} \ No newline at end of file diff --git a/test/modules/llm/Elsa.OpenAI.Tests/Services/OpenAIClientFactoryTests.cs b/test/modules/llm/Elsa.OpenAI.Tests/Services/OpenAIClientFactoryTests.cs new file mode 100644 index 00000000..010dc2f8 --- /dev/null +++ b/test/modules/llm/Elsa.OpenAI.Tests/Services/OpenAIClientFactoryTests.cs @@ -0,0 +1,291 @@ +using Elsa.OpenAI.Services; +using OpenAI; +using OpenAI.Audio; +using OpenAI.Chat; +using OpenAI.Embeddings; +using OpenAI.Images; +using OpenAI.Moderations; + +namespace Elsa.OpenAI.Tests.Services; + +/// +/// Unit tests for the OpenAIClientFactory service. +/// +public class OpenAIClientFactoryTests +{ + [Fact] + public void Constructor_CreatesInstance() + { + // Act + var factory = new OpenAIClientFactory(); + + // Assert + Assert.NotNull(factory); + } + + [Fact] + public void GetClient_WithValidApiKey_ReturnsClient() + { + // Arrange + var factory = new OpenAIClientFactory(); + var apiKey = "test-api-key"; + + // Act + var client = factory.GetClient(apiKey); + + // Assert + Assert.NotNull(client); + Assert.IsType(client); + } + + [Fact] + public void GetClient_WithSameApiKey_ReturnsSameInstance() + { + // Arrange + var factory = new OpenAIClientFactory(); + var apiKey = "test-api-key"; + + // Act + var client1 = factory.GetClient(apiKey); + var client2 = factory.GetClient(apiKey); + + // Assert + Assert.Same(client1, client2); + } + + [Fact] + public void GetClient_WithDifferentApiKeys_ReturnsDifferentInstances() + { + // Arrange + var factory = new OpenAIClientFactory(); + var apiKey1 = "test-api-key-1"; + var apiKey2 = "test-api-key-2"; + + // Act + var client1 = factory.GetClient(apiKey1); + var client2 = factory.GetClient(apiKey2); + + // Assert + Assert.NotSame(client1, client2); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetClient_WithNullOrWhitespaceApiKey_ThrowsArgumentException(string? apiKey) + { + // Arrange + var factory = new OpenAIClientFactory(); + + // Act & Assert + Assert.ThrowsAny(() => factory.GetClient(apiKey!)); + } + + [Fact] + public void GetChatClient_WithValidParameters_ReturnsChatClient() + { + // Arrange + var factory = new OpenAIClientFactory(); + var model = "gpt-3.5-turbo"; + var apiKey = "test-api-key"; + + // Act + var chatClient = factory.GetChatClient(model, apiKey); + + // Assert + Assert.NotNull(chatClient); + Assert.IsType(chatClient); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetChatClient_WithNullOrWhitespaceModel_ThrowsArgumentException(string? model) + { + // Arrange + var factory = new OpenAIClientFactory(); + + // Act & Assert + Assert.ThrowsAny(() => factory.GetChatClient(model!, "test-api-key")); + } + + [Fact] + public void GetImageClient_WithValidParameters_ReturnsImageClient() + { + // Arrange + var factory = new OpenAIClientFactory(); + var model = "dall-e-3"; + var apiKey = "test-api-key"; + + // Act + var imageClient = factory.GetImageClient(model, apiKey); + + // Assert + Assert.NotNull(imageClient); + Assert.IsType(imageClient); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetImageClient_WithNullOrWhitespaceModel_ThrowsArgumentException(string? model) + { + // Arrange + var factory = new OpenAIClientFactory(); + + // Act & Assert + Assert.ThrowsAny(() => factory.GetImageClient(model!, "test-api-key")); + } + + [Fact] + public void GetAudioClient_WithValidParameters_ReturnsAudioClient() + { + // Arrange + var factory = new OpenAIClientFactory(); + var model = "whisper-1"; + var apiKey = "test-api-key"; + + // Act + var audioClient = factory.GetAudioClient(model, apiKey); + + // Assert + Assert.NotNull(audioClient); + Assert.IsType(audioClient); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetAudioClient_WithNullOrWhitespaceModel_ThrowsArgumentException(string? model) + { + // Arrange + var factory = new OpenAIClientFactory(); + + // Act & Assert + Assert.ThrowsAny(() => factory.GetAudioClient(model!, "test-api-key")); + } + + [Fact] + public void GetEmbeddingClient_WithValidParameters_ReturnsEmbeddingClient() + { + // Arrange + var factory = new OpenAIClientFactory(); + var model = "text-embedding-3-small"; + var apiKey = "test-api-key"; + + // Act + var embeddingClient = factory.GetEmbeddingClient(model, apiKey); + + // Assert + Assert.NotNull(embeddingClient); + Assert.IsType(embeddingClient); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetEmbeddingClient_WithNullOrWhitespaceModel_ThrowsArgumentException(string? model) + { + // Arrange + var factory = new OpenAIClientFactory(); + + // Act & Assert + Assert.ThrowsAny(() => factory.GetEmbeddingClient(model!, "test-api-key")); + } + + [Fact] + public void GetModerationClient_WithValidParameters_ReturnsModerationClient() + { + // Arrange + var factory = new OpenAIClientFactory(); + var model = "omni-moderation-latest"; + var apiKey = "test-api-key"; + + // Act + var moderationClient = factory.GetModerationClient(model, apiKey); + + // Assert + Assert.NotNull(moderationClient); + Assert.IsType(moderationClient); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetModerationClient_WithNullOrWhitespaceModel_ThrowsArgumentException(string? model) + { + // Arrange + var factory = new OpenAIClientFactory(); + + // Act & Assert + Assert.ThrowsAny(() => factory.GetModerationClient(model!, "test-api-key")); + } + + [Fact] + public void MultipleClientTypes_WithSameApiKey_ReuseBaseClient() + { + // Arrange + var factory = new OpenAIClientFactory(); + var apiKey = "test-api-key"; + + // Act + var baseClient1 = factory.GetClient(apiKey); + var chatClient = factory.GetChatClient("gpt-3.5-turbo", apiKey); + var baseClient2 = factory.GetClient(apiKey); + + // Assert + Assert.Same(baseClient1, baseClient2); + Assert.NotNull(chatClient); + } + + [Fact] + public void ConcurrentAccess_WithSameApiKey_ThreadSafe() + { + // Arrange + var factory = new OpenAIClientFactory(); + var apiKey = "test-api-key"; + var clients = new OpenAIClient[10]; + + // Act + Parallel.For(0, 10, i => + { + clients[i] = factory.GetClient(apiKey); + }); + + // Assert + Assert.All(clients, client => Assert.NotNull(client)); + Assert.All(clients, client => Assert.Same(clients[0], client)); + } + + [Fact] + public void ConcurrentAccess_WithDifferentApiKeys_ThreadSafe() + { + // Arrange + var factory = new OpenAIClientFactory(); + var clients = new OpenAIClient[10]; + + // Act + Parallel.For(0, 10, i => + { + clients[i] = factory.GetClient($"test-api-key-{i}"); + }); + + // Assert + Assert.All(clients, client => Assert.NotNull(client)); + + // Verify all clients are different + for (int i = 0; i < 10; i++) + { + for (int j = i + 1; j < 10; j++) + { + Assert.NotSame(clients[i], clients[j]); + } + } + } +} \ No newline at end of file