diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index b4ca0b4..8935a88 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -91,7 +91,8 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum var client = services.GetKeyedService(options?.Client ?? throw new InvalidOperationException($"A client must be specified for agent '{name}' in configuration section '{section}'.")) - ?? throw new InvalidOperationException($"Specified chat client '{options?.Client}' for agent '{name}' is not registered."); + ?? services.GetKeyedService(new ServiceKey(options!.Client)) + ?? throw new InvalidOperationException($"Specified chat client '{options!.Client}' for agent '{name}' is not registered."); var provider = client.GetService()?.ProviderName; ChatOptions? chat = provider == "xai" diff --git a/src/Agents/AddAIAgentsExtensions.cs b/src/Agents/ConfigurableAgentsExtensions.cs similarity index 74% rename from src/Agents/AddAIAgentsExtensions.cs rename to src/Agents/ConfigurableAgentsExtensions.cs index 6b5e6b6..386b11c 100644 --- a/src/Agents/AddAIAgentsExtensions.cs +++ b/src/Agents/ConfigurableAgentsExtensions.cs @@ -1,18 +1,20 @@ using System.ComponentModel; +using Devlooped.Agents.AI; using Devlooped.Extensions.AI; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; +using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; -namespace Devlooped.Agents.AI; +namespace Microsoft.Extensions.DependencyInjection; /// /// Adds configuration-driven agents to an application host. /// [EditorBrowsable(EditorBrowsableState.Never)] -public static class AddAIAgentsExtensions +public static class ConfigurableAgentsExtensions { /// /// Adds AI agents to the host application builder based on configuration. @@ -52,8 +54,17 @@ public static TBuilder AddAIAgents(this TBuilder builder, Action sp.GetRequiredKeyedService(name))); } return builder; } + + /// Gets an AI agent by name (case-insensitive) from the service provider. + public static AIAgent? GetIAAgent(this IServiceProvider services, string name) + => services.GetKeyedService(name) ?? services.GetKeyedService(new ServiceKey(name)); } \ No newline at end of file diff --git a/src/Extensions/ConfigurableChatClient.cs b/src/Extensions/ConfigurableChatClient.cs index a272440..850a970 100644 --- a/src/Extensions/ConfigurableChatClient.cs +++ b/src/Extensions/ConfigurableChatClient.cs @@ -3,7 +3,6 @@ using Azure; using Azure.AI.Inference; using Azure.AI.OpenAI; -using Azure.Core; using Devlooped.Extensions.AI.Grok; using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; @@ -58,11 +57,11 @@ public ConfigurableChatClient(IConfiguration configuration, ILogger logger, stri public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => innerClient.GetResponseAsync(messages, options, cancellationToken); /// - public object? GetService(Type serviceType, object? serviceKey = null) - => innerClient.GetService(serviceType, serviceKey); - /// public IAsyncEnumerable GetStreamingResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => innerClient.GetStreamingResponseAsync(messages, options, cancellationToken); + /// + public object? GetService(Type serviceType, object? serviceKey = null) + => innerClient.GetService(serviceType, serviceKey); /// Exposes the optional configured for the client. [EditorBrowsable(EditorBrowsableState.Never)] @@ -159,4 +158,4 @@ internal class ConfigurableAzureOptions : AzureOpenAIClientOptions public string? ApiKey { get; set; } public string? ModelId { get; set; } } -} +} \ No newline at end of file diff --git a/src/Extensions/AddChatClientsExtensions.cs b/src/Extensions/ConfigurableChatClientExtensions.cs similarity index 85% rename from src/Extensions/AddChatClientsExtensions.cs rename to src/Extensions/ConfigurableChatClientExtensions.cs index 84e1653..fe8216d 100644 --- a/src/Extensions/AddChatClientsExtensions.cs +++ b/src/Extensions/ConfigurableChatClientExtensions.cs @@ -1,20 +1,20 @@ using System.ComponentModel; +using Devlooped.Extensions.AI; using Devlooped.Extensions.AI.OpenAI; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using OpenAI; -namespace Devlooped.Extensions.AI; +namespace Microsoft.Extensions.DependencyInjection; /// /// Adds configuration-driven chat clients to an application host or service collection. /// [EditorBrowsable(EditorBrowsableState.Never)] -public static class AddChatClientsExtensions +public static class ConfigurableChatClientExtensions { /// /// Adds configuration-driven chat clients to the host application builder. @@ -69,11 +69,19 @@ public static IServiceCollection AddChatClients(this IServiceCollection services return client; }, options?.Lifetime ?? ServiceLifetime.Singleton)); + + services.TryAdd(new ServiceDescriptor(typeof(IChatClient), new ServiceKey(id), + factory: (sp, _) => sp.GetRequiredKeyedService(id), + options?.Lifetime ?? ServiceLifetime.Singleton)); } return services; } + /// Gets a chat client by id (case-insensitive) from the service provider. + public static IChatClient? GetChatClient(this IServiceProvider services, string id) + => services.GetKeyedService(id) ?? services.GetKeyedService(new ServiceKey(id)); + internal class ChatClientOptions : OpenAIClientOptions { public string? ApiKey { get; set; } diff --git a/src/Extensions/ServiceKey.cs b/src/Extensions/ServiceKey.cs new file mode 100644 index 0000000..65b81a6 --- /dev/null +++ b/src/Extensions/ServiceKey.cs @@ -0,0 +1,34 @@ +namespace Devlooped.Extensions.AI; + +/// +/// An alternative service key that provides more flexible key comparison (case insensitive by default). +/// +/// The service key for use in the dependency injection container. +/// The comparer used for equality comparisons, defaulting to if not specified. +public readonly struct ServiceKey(string key, IEqualityComparer? comparer = default) : IEquatable +{ + readonly IEqualityComparer comparer = comparer ?? StringComparer.OrdinalIgnoreCase; + + /// + /// Gets the original value of the service key. + /// + public string Value => key; + + /// + public bool Equals(ServiceKey other) => comparer.Equals(Value, other.Value); + + /// + public override bool Equals(object? obj) => obj is ServiceKey k && Equals(k); + + /// + public override int GetHashCode() => comparer.GetHashCode(Value); + + /// + public override string ToString() => Value; + + /// Compares both keys for equality. + public static bool operator ==(ServiceKey left, ServiceKey right) => left.Equals(right); + + /// Compares both keys for inequality. + public static bool operator !=(ServiceKey left, ServiceKey right) => !(left == right); +} \ No newline at end of file diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 2294bd9..6e8dab8 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -38,6 +38,29 @@ public void CanConfigureAgent() Assert.Equal("Helpful chat agent", agent.Description); } + [Fact] + public void CanGetFromAlternativeKey() + { + var builder = new HostApplicationBuilder(); + + builder.Configuration.AddInMemoryCollection(new Dictionary + { + ["ai:clients:Chat:modelid"] = "gpt-4.1-nano", + ["ai:clients:Chat:apikey"] = "sk-asdfasdf", + // NOTE: mismatched case in client id + ["ai:agents:bot:client"] = "chat", + }); + + builder.AddAIAgents(); + + var app = builder.Build(); + + var agent = app.Services.GetRequiredKeyedService(new ServiceKey("Bot")); + + Assert.Equal("bot", agent.Name); + Assert.Same(agent, app.Services.GetIAAgent("Bot")); + } + [Fact] public void DedentsDescriptionAndInstructions() { diff --git a/src/Tests/ConfigurableClientTests.cs b/src/Tests/ConfigurableClientTests.cs index 319cd66..415d932 100644 --- a/src/Tests/ConfigurableClientTests.cs +++ b/src/Tests/ConfigurableClientTests.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Devlooped.Extensions.AI; @@ -33,6 +32,29 @@ public void CanConfigureClients() Assert.Equal("xai", grok.GetRequiredService().ProviderName); } + [Fact] + public void CanGetFromAlternativeKey() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["ai:clients:Grok:modelid"] = "grok-4-fast", + ["ai:clients:Grok:ApiKey"] = "xai-asdfasdf", + ["ai:clients:Grok:endpoint"] = "https://api.x.ai", + }) + .Build(); + + var services = new ServiceCollection() + .AddSingleton(configuration) + .AddChatClients(configuration) + .BuildServiceProvider(); + + var grok = services.GetRequiredKeyedService(new ServiceKey("grok")); + + Assert.Equal("xai", grok.GetRequiredService().ProviderName); + Assert.Same(grok, services.GetChatClient("grok")); + } + [Fact] public void CanOverrideClientId() { diff --git a/src/Tests/Extensions/Attributes.cs b/src/Tests/Extensions/Attributes.cs index a98cd3b..5841e48 100644 --- a/src/Tests/Extensions/Attributes.cs +++ b/src/Tests/Extensions/Attributes.cs @@ -1,6 +1,4 @@ #nullable enable -using System; -using System.Collections.Generic; using System.Runtime.InteropServices; using Microsoft.Extensions.Configuration; diff --git a/src/Tests/Extensions/Configuration.cs b/src/Tests/Extensions/Configuration.cs index 394c787..a8f0d29 100644 --- a/src/Tests/Extensions/Configuration.cs +++ b/src/Tests/Extensions/Configuration.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Threading.Tasks; +using System.Reflection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; diff --git a/src/Tests/Extensions/Logging.cs b/src/Tests/Extensions/Logging.cs index f71fa67..4bfb81c 100644 --- a/src/Tests/Extensions/Logging.cs +++ b/src/Tests/Extensions/Logging.cs @@ -1,9 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; public static class LoggerFactoryExtensions { diff --git a/src/Tests/OpenAIOptions.cs b/src/Tests/OpenAIOptions.cs index fefcc8f..d3ea3e6 100644 --- a/src/Tests/OpenAIOptions.cs +++ b/src/Tests/OpenAIOptions.cs @@ -1,10 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Devlooped.Extensions.AI; +namespace Devlooped.Extensions.AI; record OpenAIOptions(string Key, string[] Vectors) {