Initial draft of actor runtime abstractions (#197)

* Initial draft of actor runtime abstractions
This commit is contained in:
Reuben Bond
2025-07-22 13:05:58 -07:00
committed by GitHub
Unverified
parent 8f2d3da80d
commit 41d441420e
108 changed files with 7445 additions and 58 deletions
@@ -0,0 +1,172 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents.Runtime;
internal static class ActorFrameworkWebApplicationExtensions
{
public static void MapAgents(this WebApplication app)
{
app.MapPost(
"/invocations/actor/{name}/{sessionId}/{requestId}", async (
string name,
string sessionId,
string requestId,
[FromQuery] bool? stream,
[FromBody] JsonElement request,
HttpContext context,
ILogger<Program> logger,
IActorClient actorClient,
CancellationToken cancellationToken) =>
{
var stopwatch = Stopwatch.StartNew();
var streamRequested = stream == true;
Log.ActorInvocationStarted(logger, name, sessionId, requestId, streamRequested);
Log.ActorRequestReceived(logger, requestId, request.GetRawText().Length, streamRequested);
try
{
var responseHandle = await actorClient.SendRequestAsync(new ActorRequest(new ActorId(name, sessionId), requestId, method: "run", @params: request), cancellationToken);
Log.ActorRequestSent(logger, requestId, name, sessionId);
if (!responseHandle.TryGetResponse(out var response))
{
Log.ActorResponseHandleObtained(logger, requestId, false);
if (stream == true)
{
Log.SseStreamingStarted(logger, requestId);
// If no response is available and streaming is requested, stream the response handle.
var result = await StreamResponse(context, responseHandle, cancellationToken);
Log.ActorInvocationCompleted(logger, name, sessionId, requestId, RequestStatus.Pending, stopwatch.ElapsedMilliseconds);
return result;
}
// Otherwise, wait for a response to become available.
Log.WaitingForActorResponse(logger, requestId);
response = await responseHandle.GetResponseAsync(cancellationToken);
}
else
{
Log.ActorResponseHandleObtained(logger, requestId, true);
}
Log.ActorResponseReceived(logger, requestId, response.Status);
var processResult = await ProcessResponse(name, sessionId, requestId, stream, context, responseHandle, response, cancellationToken);
Log.ActorInvocationCompleted(logger, name, sessionId, requestId, response.Status, stopwatch.ElapsedMilliseconds);
return processResult;
}
catch (Exception ex)
{
Log.ActorInvocationFailed(logger, ex, name, sessionId, requestId, stopwatch.ElapsedMilliseconds);
return Results.Problem("An error occurred processing the request.", statusCode: 500);
}
static async Task<IResult> StreamResponse(HttpContext context, ActorResponseHandle responseHandle, CancellationToken cancellationToken)
{
var requestId = context.Request.RouteValues["requestId"]?.ToString() ?? "unknown";
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
Log.SseStreamingStarted(logger, requestId);
InitializeSseResponse(context);
await context.Response.Body.FlushAsync(cancellationToken);
var updateCount = 0;
try
{
await foreach (var progress in responseHandle.WatchUpdatesAsync(cancellationToken))
{
// Properly serialize the progress data as JSON and escape for SSE
var progressJson = JsonSerializer.Serialize(progress.Data, (JsonSerializerOptions?)null);
var eventData = JsonSerializer.Serialize(new { @event = JsonDocument.Parse(progressJson).RootElement });
var eventText = $"data: {eventData}\n\n";
await context.Response.WriteAsync(eventText, cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
updateCount++;
Log.SseProgressUpdateSent(logger, requestId, updateCount);
}
// Send completion marker
await context.Response.WriteAsync("data: completed\n\n", cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
Log.SseStreamingCompleted(logger, requestId, updateCount);
}
catch (OperationCanceledException)
{
Log.SseStreamingCancelled(logger, requestId);
}
catch (Exception ex)
{
Log.SseStreamingError(logger, ex, requestId);
}
// TODO: refactor the enclosing method so we don't need to return a result here.
return Results.Empty;
}
static void InitializeSseResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
context.Response.Headers.CacheControl = "no-cache,no-store";
context.Response.Headers.Connection = "keep-alive";
// Make sure we disable all response buffering for SSE.
context.Response.Headers.ContentEncoding = "identity";
context.Features.GetRequiredFeature<IHttpResponseBodyFeature>().DisableBuffering();
}
static async Task<IResult> ProcessResponse(
string name,
string sessionId,
string requestId,
bool? stream,
HttpContext context,
ActorResponseHandle responseHandle,
ActorResponse response,
CancellationToken cancellationToken)
{
var logger = context.RequestServices.GetRequiredService<ILogger<Program>>();
var isStreaming = stream != false && response.Status == RequestStatus.Pending;
Log.ProcessingActorResponse(logger, requestId, response.Status, isStreaming);
var result = response.Status switch
{
// If the response is pending & streaming is disabled, return a 202 Accepted with the messageId.
RequestStatus.Pending when stream == false => Results.Accepted($"/invocations/actor/{name}/{sessionId}/{requestId}"),
// If streaming is not explicitly disabled, stream the response back.
RequestStatus.Pending => await StreamResponse(context, responseHandle, cancellationToken),
RequestStatus.Completed => Results.Ok(response.Data),
// If the response failed, we can return a 500 Internal Server Error.
RequestStatus.Failed => Results.Problem("The invocation failed.", statusCode: 500),
RequestStatus.NotFound => Results.NotFound(new { message = "Not found." }),// If the actor is not found, we can return a 404 Not Found.
_ => throw new NotSupportedException($"Unsupported request status: {response.Status}"),
};
var responseType = response.Status switch
{
RequestStatus.Pending when stream == false => "Accepted",
RequestStatus.Pending => "Streaming",
RequestStatus.Completed => "Ok",
RequestStatus.Failed => "Problem",
RequestStatus.NotFound => "NotFound",
_ => "Unknown"
};
Log.ActorResponseProcessed(logger, requestId, responseType);
return result;
}
})
.WithName("Invocations");
}
}
@@ -0,0 +1,21 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
using HelloHttpApi.ApiService;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
/// <summary>
/// Source-generated JSON type information for use by all Agents implementations.
/// </summary>
[JsonSourceGenerationOptions(
JsonSerializerDefaults.Web,
UseStringEnumConverter = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false)]
[JsonSerializable(typeof(ChatMessage))]
[JsonSerializable(typeof(List<ChatMessage>))]
[JsonSerializable(typeof(ChatClientAgentThread))]
[JsonSerializable(typeof(ChatClientAgentRunRequest))]
[JsonSerializable(typeof(AgentRunResponseUpdate))]
internal sealed partial class AgentsJsonContext : JsonSerializerContext;
@@ -0,0 +1,124 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using HelloHttpApi.ApiService;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
internal sealed class ChatClientAgentActor(ChatClientAgent agent, JsonSerializerOptions jsonSerializerOptions, IActorRuntimeContext context, ILogger<ChatClientAgentActor> logger) : IActor
{
private string? _etag;
private ChatClientAgentThread? _thread;
public ValueTask DisposeAsync() => default;
public async ValueTask RunAsync(CancellationToken cancellationToken)
{
Log.ActorStarted(logger, context.ActorId.ToString(), agent.Name ?? "Unknown");
await Task.Yield();
// Restore thread state
var response = await context.ReadAsync(
new ActorReadOperationBatch([new GetValueOperation("thread")]),
cancellationToken).ConfigureAwait(false);
this._etag = response.ETag;
if (response.Results[0] is GetValueResult threadResult)
{
if (threadResult.Value is { } threadJson)
{
// Deserialize the thread state if it exist
this._thread = threadJson.Deserialize<ChatClientAgentThread>(
(JsonTypeInfo<ChatClientAgentThread>)jsonSerializerOptions.GetTypeInfo(typeof(ChatClientAgentThread)));
}
}
this._thread ??= (ChatClientAgentThread)agent.GetNewThread();
Log.ThreadStateRestored(logger, context.ActorId.ToString(), response.Results[0] is GetValueResult { Value: not null });
while (!cancellationToken.IsCancellationRequested)
{
try
{
await foreach (var message in context.WatchMessagesAsync(cancellationToken).ConfigureAwait(false))
{
switch (message.Type)
{
case ActorMessageType.Request:
await this.HandleAgentRequestAsync((ActorRequestMessage)message, cancellationToken).ConfigureAwait(false);
break;
case ActorMessageType.Response:
// Handle response messages if needed
break;
default:
Log.UnknownMessageType(logger, message.Type.ToString(), context.ActorId.ToString());
break;
}
}
}
catch (Exception ex)
{
Log.ErrorProcessingMessages(logger, ex, context.ActorId.ToString());
}
}
}
private async Task HandleAgentRequestAsync(ActorRequestMessage message, CancellationToken cancellationToken)
{
var requestId = message.MessageId;
Debug.Assert(this._thread is not null);
Debug.Assert(this._etag is not null);
// Parse the request to get the agent run parameters
List<ChatMessage>? messages;
if (message.Params is { } payload)
{
var arg = payload.Deserialize<ChatClientAgentRunRequest>(
(JsonTypeInfo<ChatClientAgentRunRequest>)jsonSerializerOptions.GetTypeInfo(typeof(ChatClientAgentRunRequest)));
messages = arg?.Messages;
}
messages ??= [];
Log.ProcessingAgentRequest(logger, requestId, context.ActorId.ToString(), messages.Count);
try
{
var typeInfo = (JsonTypeInfo<AgentRunResponseUpdate>)jsonSerializerOptions.GetTypeInfo(typeof(AgentRunResponseUpdate));
var i = 0;
var updates = new List<AgentRunResponseUpdate>();
await foreach (var update in agent.RunStreamingAsync(messages, this._thread, cancellationToken: cancellationToken).ConfigureAwait(false))
{
var updateJson = JsonSerializer.SerializeToElement(update, typeInfo);
context.OnProgressUpdate(requestId, i++, updateJson);
updates.Add(update);
Log.AgentStreamingUpdate(logger, requestId, i);
}
var serializedRunResponse = JsonSerializer.SerializeToElement(
updates.ToAgentRunResponse(),
(JsonTypeInfo<AgentRunResponse>)jsonSerializerOptions.GetTypeInfo(typeof(AgentRunResponse)));
var writeResponse = await context.WriteAsync(
new(this._etag, [new UpdateRequestOperation(requestId, RequestStatus.Completed, serializedRunResponse)]), cancellationToken)
.ConfigureAwait(false);
if (!writeResponse.Success)
{
Log.WriteOperationFailed(logger, context.ActorId.ToString(), requestId);
}
else
{
Log.AgentRequestCompleted(logger, requestId, updates.Count);
}
this._etag = writeResponse.ETag;
}
catch (Exception exception)
{
Log.AgentRequestFailed(logger, exception, requestId, context.ActorId.ToString());
// TODO: Retry later?
}
}
}
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
namespace HelloHttpApi.ApiService;
public sealed class ChatClientAgentRunRequest
{
[JsonPropertyName("messages")]
public List<ChatMessage> Messages { get; set; } = [];
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Abstractions\Microsoft.Extensions.AI.Agents.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime.Abstractions\Microsoft.Extensions.AI.Agents.Runtime.Abstractions.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents.Runtime\Microsoft.Extensions.AI.Agents.Runtime.csproj" />
<ProjectReference Include="..\..\..\src\Microsoft.Extensions.AI.Agents\Microsoft.Extensions.AI.Agents.csproj" />
<ProjectReference Include="..\HelloHttpApi.ServiceDefaults\HelloHttpApi.ServiceDefaults.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Azure.AI.OpenAI" />
<PackageReference Include="Aspire.Hosting.Azure.CognitiveServices" />
<PackageReference Include="CommunityToolkit.Aspire.OllamaSharp" />
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
<PackageReference Include="Microsoft.Extensions.AI.AzureAIInference" />
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
</ItemGroup>
</Project>
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Agents;
using Microsoft.Extensions.AI.Agents.Runtime;
namespace HelloHttpApi.ApiService;
public static class HostApplicationBuilderAgentExtensions
{
public static IHostApplicationBuilder AddChatClientAgent(this IHostApplicationBuilder builder, string name, string instructions, string? chatClientKey = null)
{
var agentKey = $"agent:{name}";
builder.Services.AddKeyedSingleton(agentKey, (sp, key) =>
{
var chatClient = chatClientKey is null ? sp.GetRequiredService<IChatClient>() : sp.GetRequiredKeyedService<IChatClient>(chatClientKey);
return new ChatClientAgent(chatClient, instructions, name);
});
var actorBuilder = builder.AddActorRuntime();
actorBuilder.AddActorType(
new ActorType(agentKey),
(sp, ctx) => new ChatClientAgentActor(
sp.GetRequiredKeyedService<ChatClientAgent>(agentKey),
sp.GetService<JsonSerializerOptions>() ?? JsonSerializerOptions.Web,
ctx,
sp.GetRequiredService<ILogger<ChatClientAgentActor>>()));
return builder;
}
}
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace HelloHttpApi.ApiService;
public class InvocationResponse
{
[JsonPropertyName("response")]
public JsonElement Response { get; set; }
[JsonPropertyName("status")]
public string? Status { get; set; } = "success";
}
@@ -0,0 +1,140 @@
// Copyright (c) Microsoft. All rights reserved.
using HelloHttpApi.ApiService;
using Microsoft.Extensions.AI.Agents.Runtime;
/// <summary>
/// High-performance logging messages using LoggerMessage source generator.
/// </summary>
internal static partial class Log
{
// API endpoint logging
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor invocation started: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Stream={StreamRequested}")]
public static partial void ActorInvocationStarted(ILogger logger, string actorName, string sessionId, string requestId, bool streamRequested);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor invocation completed: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Status={Status}, Duration={DurationMs}ms")]
public static partial void ActorInvocationCompleted(ILogger logger, string actorName, string sessionId, string requestId, RequestStatus status, long durationMs);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Actor invocation failed: Name={ActorName}, SessionId={SessionId}, RequestId={RequestId}, Duration={DurationMs}ms")]
public static partial void ActorInvocationFailed(ILogger logger, Exception exception, string actorName, string sessionId, string requestId, long durationMs);
// SSE streaming logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "SSE streaming started for request: {RequestId}")]
public static partial void SseStreamingStarted(ILogger logger, string requestId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "SSE progress update sent: RequestId={RequestId}, UpdateCount={UpdateCount}")]
public static partial void SseProgressUpdateSent(ILogger logger, string requestId, int updateCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "SSE streaming completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")]
public static partial void SseStreamingCompleted(ILogger logger, string requestId, int totalUpdates);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "SSE streaming cancelled: RequestId={RequestId}")]
public static partial void SseStreamingCancelled(ILogger logger, string requestId);
[LoggerMessage(
Level = LogLevel.Error,
Message = "SSE streaming error: RequestId={RequestId}")]
public static partial void SseStreamingError(ILogger logger, Exception exception, string requestId);
// Response processing logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Processing actor response: RequestId={RequestId}, Status={Status}, IsStreaming={IsStreaming}")]
public static partial void ProcessingActorResponse(ILogger logger, string requestId, RequestStatus status, bool isStreaming);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor response processed successfully: RequestId={RequestId}, ResponseType={ResponseType}")]
public static partial void ActorResponseProcessed(ILogger logger, string requestId, string responseType);
// Ping endpoint logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Ping endpoint accessed: Status={Status}, TimeOfLastUpdate={TimeOfLastUpdate}")]
public static partial void PingEndpointAccessed(ILogger logger, PingResponseStatus status, long timeOfLastUpdate);
// Request/Response logging
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor request received: RequestId={RequestId}, PayloadSize={PayloadSize} bytes, Stream={StreamRequested}")]
public static partial void ActorRequestReceived(ILogger logger, string requestId, int payloadSize, bool streamRequested);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor request sent to runtime: RequestId={RequestId}, ActorName={ActorName}, SessionId={SessionId}")]
public static partial void ActorRequestSent(ILogger logger, string requestId, string actorName, string sessionId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor response handle obtained: RequestId={RequestId}, HasImmediateResponse={HasImmediateResponse}")]
public static partial void ActorResponseHandleObtained(ILogger logger, string requestId, bool hasImmediateResponse);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Waiting for actor response: RequestId={RequestId}")]
public static partial void WaitingForActorResponse(ILogger logger, string requestId);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Actor response received: RequestId={RequestId}, Status={Status}")]
public static partial void ActorResponseReceived(ILogger logger, string requestId, RequestStatus status);
// ChatClientAgentActor logging
[LoggerMessage(
Level = LogLevel.Information,
Message = "Actor started: ActorId={ActorId}, AgentName={AgentName}")]
public static partial void ActorStarted(ILogger logger, string actorId, string agentName);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Thread state restored: ActorId={ActorId}, HasExistingThread={HasExistingThread}")]
public static partial void ThreadStateRestored(ILogger logger, string actorId, bool hasExistingThread);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Processing agent request: RequestId={RequestId}, ActorId={ActorId}, MessageCount={MessageCount}")]
public static partial void ProcessingAgentRequest(ILogger logger, string requestId, string actorId, int messageCount);
[LoggerMessage(
Level = LogLevel.Debug,
Message = "Agent streaming update: RequestId={RequestId}, UpdateNumber={UpdateNumber}")]
public static partial void AgentStreamingUpdate(ILogger logger, string requestId, int updateNumber);
[LoggerMessage(
Level = LogLevel.Information,
Message = "Agent request completed: RequestId={RequestId}, TotalUpdates={TotalUpdates}")]
public static partial void AgentRequestCompleted(ILogger logger, string requestId, int totalUpdates);
[LoggerMessage(
Level = LogLevel.Error,
Message = "Agent request failed: RequestId={RequestId}, ActorId={ActorId}")]
public static partial void AgentRequestFailed(ILogger logger, Exception exception, string requestId, string actorId);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Unknown message type received: MessageType={MessageType}, ActorId={ActorId}")]
public static partial void UnknownMessageType(ILogger logger, string messageType, string actorId);
[LoggerMessage(
Level = LogLevel.Warning,
Message = "Error processing messages: ActorId={ActorId}")]
public static partial void ErrorProcessingMessages(ILogger logger, Exception exception, string actorId);
[LoggerMessage(
Level = LogLevel.Error,
Message = "Write operation failed: ActorId={ActorId}, RequestId={RequestId}")]
public static partial void WriteOperationFailed(ILogger logger, string actorId, string requestId);
}
@@ -0,0 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
namespace HelloHttpApi.ApiService;
public class PingResponse(PingResponseStatus status, long timeOfLastUpdate)
{
[JsonPropertyName("status")]
public PingResponseStatus Status { get; } = status;
[JsonPropertyName("time_of_last_update")]
public long TimeOfLastUpdate { get; } = timeOfLastUpdate;
}
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.
namespace HelloHttpApi.ApiService;
public enum PingResponseStatus
{
Healthy,
HealthyBusy,
}
@@ -0,0 +1,32 @@
// Copyright (c) Microsoft. All rights reserved.
using HelloHttpApi.ApiService;
using HelloHttpApi.ApiService.Utilities;
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire client integrations.
builder.AddServiceDefaults();
// Add services to the container.
builder.Services.AddProblemDetails();
// Configure the chat model and our agent.
builder.AddKeyedChatClient("chat-model");
builder.AddChatClientAgent(
name: "pirate",
instructions: "You are a pirate. Speak like a pirate.",
chatClientKey: "chat-model");
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseExceptionHandler();
// Map the agents HTTP endpoints
app.MapAgents();
app.MapDefaultEndpoints();
app.Run();
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5390",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7373;http://localhost:5390",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,80 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Data.Common;
using System.Diagnostics.CodeAnalysis;
namespace HelloHttpApi.ApiService.Utilities;
public class ChatClientConnectionInfo
{
public Uri? Endpoint { get; init; }
public required string SelectedModel { get; init; }
public ClientChatProvider Provider { get; init; }
public string? AccessKey { get; init; }
// Example connection string:
// Endpoint=https://localhost:4523;Model=phi3.5;AccessKey=1234;Provider=ollama;
public static bool TryParse(string? connectionString, [NotNullWhen(true)] out ChatClientConnectionInfo? settings)
{
if (string.IsNullOrEmpty(connectionString))
{
settings = null;
return false;
}
var connectionBuilder = new DbConnectionStringBuilder
{
ConnectionString = connectionString
};
Uri? endpoint = null;
if (connectionBuilder.ContainsKey("Endpoint") && Uri.TryCreate(connectionBuilder["Endpoint"].ToString(), UriKind.Absolute, out endpoint))
{
}
string? model = null;
if (connectionBuilder.ContainsKey("Model"))
{
model = (string)connectionBuilder["Model"];
}
string? accessKey = null;
if (connectionBuilder.ContainsKey("AccessKey"))
{
accessKey = (string)connectionBuilder["AccessKey"];
}
var provider = ClientChatProvider.Unknown;
if (connectionBuilder.ContainsKey("Provider"))
{
var providerValue = (string)connectionBuilder["Provider"];
Enum.TryParse(providerValue, ignoreCase: true, out provider);
}
if (endpoint is null && provider != ClientChatProvider.OpenAI || model is null || provider == ClientChatProvider.Unknown)
{
settings = null;
return false;
}
settings = new ChatClientConnectionInfo
{
Endpoint = endpoint,
SelectedModel = model,
AccessKey = accessKey,
Provider = provider
};
return true;
}
}
public enum ClientChatProvider
{
Unknown,
Ollama,
OpenAI,
AzureOpenAI,
AzureAIInference,
}
@@ -0,0 +1,144 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure;
using Azure.AI.Inference;
using HelloHttpApi.ApiService.Utilities;
using Microsoft.Extensions.AI;
using OllamaSharp;
namespace HelloHttpApi.ApiService.Utilities;
public static class ChatClientExtensions
{
public static ChatClientBuilder AddChatClient(this IHostApplicationBuilder builder, string connectionName)
{
var cs = builder.Configuration.GetConnectionString(connectionName);
if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo))
{
throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'.");
}
var chatClientBuilder = connectionInfo.Provider switch
{
ClientChatProvider.Ollama => builder.AddOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddAzureOpenAIClient(connectionName).AddChatClient(connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
// Add OpenTelemetry tracing for the ChatClient activity source
chatClientBuilder.UseOpenTelemetry().UseLogging();
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI"));
return chatClientBuilder;
}
private static ChatClientBuilder AddOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.AddOpenAIClient(connectionName, settings =>
{
settings.Endpoint = connectionInfo.Endpoint;
settings.Key = connectionInfo.AccessKey;
})
.AddChatClient(connectionInfo.SelectedModel);
}
private static ChatClientBuilder AddAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.Services.AddChatClient(sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
}
private static ChatClientBuilder AddOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
builder.Services.AddHttpClient(httpKey, c =>
{
c.BaseAddress = connectionInfo.Endpoint;
});
return builder.Services.AddChatClient(sp =>
{
// Create a client for the Ollama API using the http client factory
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient(httpKey);
return new OllamaApiClient(client, connectionInfo.SelectedModel);
});
}
public static ChatClientBuilder AddKeyedChatClient(this IHostApplicationBuilder builder, string connectionName)
{
var cs = builder.Configuration.GetConnectionString(connectionName);
if (!ChatClientConnectionInfo.TryParse(cs, out var connectionInfo))
{
throw new InvalidOperationException($"Invalid connection string: {cs}. Expected format: 'Endpoint=endpoint;AccessKey=your_access_key;Model=model_name;Provider=ollama/openai/azureopenai;'.");
}
var chatClientBuilder = connectionInfo.Provider switch
{
ClientChatProvider.Ollama => builder.AddKeyedOllamaClient(connectionName, connectionInfo),
ClientChatProvider.OpenAI => builder.AddKeyedOpenAIClient(connectionName, connectionInfo),
ClientChatProvider.AzureOpenAI => builder.AddKeyedAzureOpenAIClient(connectionName).AddKeyedChatClient(connectionName, connectionInfo.SelectedModel),
ClientChatProvider.AzureAIInference => builder.AddKeyedAzureInferenceClient(connectionName, connectionInfo),
_ => throw new NotSupportedException($"Unsupported provider: {connectionInfo.Provider}")
};
// Add OpenTelemetry tracing for the ChatClient activity source
chatClientBuilder.UseOpenTelemetry().UseLogging();
builder.Services.AddOpenTelemetry().WithTracing(t => t.AddSource("Experimental.Microsoft.Extensions.AI"));
return chatClientBuilder;
}
private static ChatClientBuilder AddKeyedOpenAIClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.AddKeyedOpenAIClient(connectionName, settings =>
{
settings.Endpoint = connectionInfo.Endpoint;
settings.Key = connectionInfo.AccessKey;
})
.AddKeyedChatClient(connectionName, connectionInfo.SelectedModel);
}
private static ChatClientBuilder AddKeyedAzureInferenceClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
return builder.Services.AddKeyedChatClient(connectionName, sp =>
{
var credential = new AzureKeyCredential(connectionInfo.AccessKey!);
var client = new ChatCompletionsClient(connectionInfo.Endpoint, credential, new AzureAIInferenceClientOptions());
return client.AsIChatClient(connectionInfo.SelectedModel);
});
}
private static ChatClientBuilder AddKeyedOllamaClient(this IHostApplicationBuilder builder, string connectionName, ChatClientConnectionInfo connectionInfo)
{
var httpKey = $"{connectionName}_http";
builder.Services.AddHttpClient(httpKey, c =>
{
c.BaseAddress = connectionInfo.Endpoint;
});
return builder.Services.AddKeyedChatClient(connectionName, sp =>
{
// Create a client for the Ollama API using the http client factory
var client = sp.GetRequiredService<IHttpClientFactory>().CreateClient(httpKey);
return new OllamaApiClient(client, connectionInfo.SelectedModel);
});
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
}
}
}
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}