Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/Agents/ConfigurableAIAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ public override IAsyncEnumerable<AgentRunResponseUpdate> RunStreamingAsync(IEnum

var client = services.GetKeyedService<IChatClient>(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<IChatClient>(new ServiceKey(options!.Client))
?? throw new InvalidOperationException($"Specified chat client '{options!.Client}' for agent '{name}' is not registered.");

var provider = client.GetService<ChatClientMetadata>()?.ProviderName;
ChatOptions? chat = provider == "xai"
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Adds configuration-driven agents to an application host.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class AddAIAgentsExtensions
public static class ConfigurableAgentsExtensions
{
/// <summary>
/// Adds AI agents to the host application builder based on configuration.
Expand Down Expand Up @@ -52,8 +54,17 @@ public static TBuilder AddAIAgents<TBuilder>(this TBuilder builder, Action<strin

return agent;
});

// Also register for case-insensitive lookup, but without duplicating the entry in
// the AgentCatalog, since that will always resolve from above.
builder.Services.TryAdd(ServiceDescriptor.KeyedSingleton(new ServiceKey(name), (sp, key)
=> sp.GetRequiredKeyedService<AIAgent>(name)));
}

return builder;
}

/// <summary>Gets an AI agent by name (case-insensitive) from the service provider.</summary>
public static AIAgent? GetIAAgent(this IServiceProvider services, string name)
=> services.GetKeyedService<AIAgent>(name) ?? services.GetKeyedService<AIAgent>(new ServiceKey(name));
}
9 changes: 4 additions & 5 deletions src/Extensions/ConfigurableChatClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,11 +57,11 @@
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> innerClient.GetResponseAsync(messages, options, cancellationToken);
/// <inheritdoc/>
public object? GetService(Type serviceType, object? serviceKey = null)
=> innerClient.GetService(serviceType, serviceKey);
/// <inheritdoc/>
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
=> innerClient.GetStreamingResponseAsync(messages, options, cancellationToken);
/// <inheritdoc/>
public object? GetService(Type serviceType, object? serviceKey = null)
=> innerClient.GetService(serviceType, serviceKey);

/// <summary>Exposes the optional <see cref="ClientPipelineOptions"/> configured for the client.</summary>
[EditorBrowsable(EditorBrowsableState.Never)]
Expand Down Expand Up @@ -119,7 +118,7 @@
var t when t == typeof(ConfigurableInferenceOptions) => section.Get<ConfigurableInferenceOptions>() as TOptions,
var t when t == typeof(ConfigurableAzureOptions) => section.Get<ConfigurableAzureOptions>() as TOptions,
#pragma warning disable SYSLIB1104 // The target type for a binder call could not be determined
_ => section.Get<TOptions>()

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)

Check warning on line 121 in src/Extensions/ConfigurableChatClient.cs

View workflow job for this annotation

GitHub Actions / build-ubuntu-latest

Binding logic was not generated for a binder call. Unsupported input patterns include generic calls, passing boxed objects, and passing types that are not 'public' or 'internal'. (https://learn.microsoft.com/dotnet/fundamentals/syslib-diagnostics/syslib1104)
#pragma warning restore SYSLIB1104 // The target type for a binder call could not be determined
};

Expand Down Expand Up @@ -159,4 +158,4 @@
public string? ApiKey { get; set; }
public string? ModelId { get; set; }
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Adds configuration-driven chat clients to an application host or service collection.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static class AddChatClientsExtensions
public static class ConfigurableChatClientExtensions
{
/// <summary>
/// Adds configuration-driven chat clients to the host application builder.
Expand Down Expand Up @@ -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<IChatClient>(id),
options?.Lifetime ?? ServiceLifetime.Singleton));
}

return services;
}

/// <summary>Gets a chat client by id (case-insensitive) from the service provider.</summary>
public static IChatClient? GetChatClient(this IServiceProvider services, string id)
=> services.GetKeyedService<IChatClient>(id) ?? services.GetKeyedService<IChatClient>(new ServiceKey(id));

internal class ChatClientOptions : OpenAIClientOptions
{
public string? ApiKey { get; set; }
Expand Down
34 changes: 34 additions & 0 deletions src/Extensions/ServiceKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace Devlooped.Extensions.AI;

/// <summary>
/// An alternative service key that provides more flexible key comparison (case insensitive by default).
/// </summary>
/// <param name="key">The service key for use in the dependency injection container.</param>
/// <param name="comparer">The comparer used for equality comparisons, defaulting to <see cref="StringComparer.OrdinalIgnoreCase"/> if not specified.</param>
public readonly struct ServiceKey(string key, IEqualityComparer<string?>? comparer = default) : IEquatable<ServiceKey>
{
readonly IEqualityComparer<string?> comparer = comparer ?? StringComparer.OrdinalIgnoreCase;

/// <summary>
/// Gets the original value of the service key.
/// </summary>
public string Value => key;

/// <inheritdoc/>
public bool Equals(ServiceKey other) => comparer.Equals(Value, other.Value);

/// <inheritdoc/>
public override bool Equals(object? obj) => obj is ServiceKey k && Equals(k);

/// <inheritdoc/>
public override int GetHashCode() => comparer.GetHashCode(Value);

/// <inheritdoc/>
public override string ToString() => Value;

/// <summary>Compares both keys for equality.</summary>
public static bool operator ==(ServiceKey left, ServiceKey right) => left.Equals(right);

/// <summary>Compares both keys for inequality.</summary>
public static bool operator !=(ServiceKey left, ServiceKey right) => !(left == right);
}
23 changes: 23 additions & 0 deletions src/Tests/ConfigurableAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string?>
{
["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<AIAgent>(new ServiceKey("Bot"));

Assert.Equal("bot", agent.Name);
Assert.Same(agent, app.Services.GetIAAgent("Bot"));
}

[Fact]
public void DedentsDescriptionAndInstructions()
{
Expand Down
24 changes: 23 additions & 1 deletion src/Tests/ConfigurableClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace Devlooped.Extensions.AI;

Expand Down Expand Up @@ -33,6 +32,29 @@ public void CanConfigureClients()
Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
}

[Fact]
public void CanGetFromAlternativeKey()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<IConfiguration>(configuration)
.AddChatClients(configuration)
.BuildServiceProvider();

var grok = services.GetRequiredKeyedService<IChatClient>(new ServiceKey("grok"));

Assert.Equal("xai", grok.GetRequiredService<ChatClientMetadata>().ProviderName);
Assert.Same(grok, services.GetChatClient("grok"));
}

[Fact]
public void CanOverrideClientId()
{
Expand Down
2 changes: 0 additions & 2 deletions src/Tests/Extensions/Attributes.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Microsoft.Extensions.Configuration;

Expand Down
7 changes: 1 addition & 6 deletions src/Tests/Extensions/Configuration.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
7 changes: 1 addition & 6 deletions src/Tests/Extensions/Logging.cs
Original file line number Diff line number Diff line change
@@ -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
{
Expand Down
8 changes: 1 addition & 7 deletions src/Tests/OpenAIOptions.cs
Original file line number Diff line number Diff line change
@@ -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)
{
Expand Down