// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; using Aspire.Hosting.ApplicationModel; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Aspire.Hosting.AgentFramework; /// /// Hosts an in-process reverse proxy that aggregates DevUI entities from multiple agent backends. /// Serves the DevUI frontend directly from the Microsoft.Agents.AI.DevUI assembly's embedded /// resources and intercepts API calls to provide multi-backend entity aggregation and request routing. /// internal sealed class DevUIAggregatorHostedService : IAsyncDisposable { private static readonly FileExtensionContentTypeProvider s_contentTypeProvider = new(); private WebApplication? _app; private readonly DevUIResource _resource; private readonly ILogger _logger; // Frontend resources loaded from the Microsoft.Agents.AI.DevUI assembly (null if unavailable) private readonly Dictionary? _frontendResources; // Maps conversation IDs to backend URLs for routing GET requests that lack agent_id context. // Populated when the aggregator routes conversation requests to a positively-resolved backend. private readonly ConcurrentDictionary _conversationBackendMap = new(StringComparer.OrdinalIgnoreCase); public DevUIAggregatorHostedService( DevUIResource resource, ILogger logger) { this._resource = resource; this._logger = logger; this._frontendResources = LoadFrontendResources(logger); } /// /// Gets the port the aggregator is listening on, available after . /// internal int AllocatedPort { get; private set; } public async Task StartAsync(CancellationToken cancellationToken) { var builder = WebApplication.CreateSlimBuilder(); builder.Logging.ClearProviders(); builder.Services.AddHttpClient("devui-proxy") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); this._app = builder.Build(); // Bind to a fixed port if one was specified on the DevUI resource; otherwise use 0 for dynamic allocation. var port = this._resource.Port ?? 0; this._app.Urls.Add($"http://127.0.0.1:{port}"); this.MapRoutes(this._app); await this._app.StartAsync(cancellationToken).ConfigureAwait(false); var serverAddresses = this._app.Services.GetRequiredService() .Features.Get(); if (serverAddresses is not null) { var address = serverAddresses.Addresses.First(); var uri = new Uri(address); this.AllocatedPort = uri.Port; this._logger.LogInformation("DevUI aggregator started on port {Port}", this.AllocatedPort); } } public async Task StopAsync(CancellationToken cancellationToken) { if (this._app is not null) { await this._app.StopAsync(cancellationToken).ConfigureAwait(false); } } public async ValueTask DisposeAsync() { if (this._app is not null) { await this._app.DisposeAsync().ConfigureAwait(false); this._app = null; } } /// /// Loads the DevUI frontend resources from the Microsoft.Agents.AI.DevUI assembly. /// The assembly embeds the Vite SPA build output as manifest resources. /// Returns null if the assembly is not available. /// private static Dictionary? LoadFrontendResources(ILogger logger) { Assembly assembly; try { assembly = Assembly.Load("Microsoft.Agents.AI.DevUI"); } catch (Exception ex) { logger.LogDebug(ex, "Microsoft.Agents.AI.DevUI assembly not found. Frontend will be proxied from backends."); return null; } var prefix = $"{assembly.GetName().Name}.resources."; var resources = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var name in assembly.GetManifestResourceNames()) { if (!name.StartsWith(prefix, StringComparison.Ordinal)) { continue; } // The DevUI middleware maps resource names by replacing dots with slashes. // Both the key and lookup use the same transform, so they match. var key = name[prefix.Length..].Replace('.', '/'); s_contentTypeProvider.TryGetContentType(name, out var contentType); resources[key] = (name, contentType ?? "application/octet-stream"); } if (resources.Count == 0) { logger.LogWarning("Microsoft.Agents.AI.DevUI assembly loaded but contains no frontend resources"); return null; } logger.LogDebug("Loaded {Count} DevUI frontend resources from assembly", resources.Count); return resources; } /// /// Serves the DevUI frontend. Uses embedded assembly resources if available, /// otherwise falls back to proxying from the first backend agent service. /// private async Task ServeDevUIFrontendAsync(HttpContext context, string? path) { // Redirect /devui to /devui/ so relative URLs in the SPA resolve correctly if (string.IsNullOrEmpty(path) && context.Request.Path.Value is { } reqPath && !reqPath.EndsWith('/')) { var redirect = reqPath + "/"; if (context.Request.QueryString.HasValue) { redirect += context.Request.QueryString.Value; } context.Response.StatusCode = StatusCodes.Status301MovedPermanently; context.Response.Headers.Location = redirect; return; } // Try embedded resources first if (this._frontendResources is not null) { var resourcePath = string.IsNullOrEmpty(path) ? "index.html" : path; if (await this.TryServeResourceAsync(context, resourcePath).ConfigureAwait(false)) { return; } // SPA fallback: serve index.html for paths without a file extension (client-side routing) if (!resourcePath.Contains('.', StringComparison.Ordinal) && await this.TryServeResourceAsync(context, "index.html").ConfigureAwait(false)) { return; } context.Response.StatusCode = StatusCodes.Status404NotFound; return; } // Fallback: proxy from the first backend that serves /devui var backends = this.ResolveBackends(); var firstBackendUrl = backends.Values.FirstOrDefault(); if (firstBackendUrl is null) { context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; context.Response.ContentType = "text/plain"; await context.Response.WriteAsync( "DevUI: No agent service backends are available yet.", context.RequestAborted).ConfigureAwait(false); return; } var targetPath = string.IsNullOrEmpty(path) ? "/devui/" : $"/devui/{path}"; await ProxyRequestAsync( context, firstBackendUrl, targetPath + context.Request.QueryString, bodyBytes: null).ConfigureAwait(false); } private async Task TryServeResourceAsync(HttpContext context, string resourcePath) { if (this._frontendResources is null) { return false; } var key = resourcePath.Replace('.', '/'); if (!this._frontendResources.TryGetValue(key, out var entry)) { return false; } Assembly assembly; try { assembly = Assembly.Load("Microsoft.Agents.AI.DevUI"); } catch { return false; } using var stream = assembly.GetManifestResourceStream(entry.ResourceName); if (stream is null) { return false; } context.Response.ContentType = entry.ContentType; context.Response.Headers.CacheControl = "no-cache, no-store"; await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); return true; } private static IResult GetMeta() { return Results.Json(new { ui_mode = "developer", version = "0.1.0", framework = "agent_framework", runtime = "dotnet", capabilities = new Dictionary { ["tracing"] = false, ["openai_proxy"] = false, ["deployment"] = false }, auth_required = false }); } private void MapRoutes(WebApplication app) { app.MapGet("/health", () => Results.Ok(new { status = "healthy" })); // Intercept API calls for multi-backend aggregation and routing app.MapGet("/v1/entities", (Delegate)this.AggregateEntitiesAsync); app.MapGet("/v1/entities/{**entityPath}", this.RouteEntityInfoAsync); app.MapPost("/v1/responses", this.RouteResponsesAsync); app.Map("/v1/conversations/{**path}", this.ProxyConversationsAsync); app.MapGet("/meta", GetMeta); // Serve the DevUI frontend from embedded assembly resources app.Map("/devui/{**path}", this.ServeDevUIFrontendAsync); } /// /// Resolves backend URLs from the resource's annotations. /// This method does not cache results to ensure late-allocated backends are always discovered. /// private Dictionary ResolveBackends() { var result = new Dictionary(StringComparer.Ordinal); foreach (var annotation in this._resource.Annotations.OfType()) { if (annotation.AgentService is not IResourceWithEndpoints rwe) { continue; } var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name; try { var endpoint = rwe.GetEndpoint("http"); if (endpoint.IsAllocated) { result[prefix] = endpoint.Url; } } catch (Exception ex) { this._logger.LogDebug(ex, "Backend '{Prefix}' endpoint not yet available", prefix); } } return result; } private async Task AggregateEntitiesAsync(HttpContext context) { var backends = this.ResolveBackends(); var allEntities = new JsonArray(); foreach (var annotation in this._resource.Annotations.OfType()) { var prefix = annotation.EntityIdPrefix ?? annotation.AgentService.Name; if (annotation.Agents.Count > 0) { // Build entities from AppHost-declared metadata — no backend call needed foreach (var agent in annotation.Agents) { allEntities.Add(new JsonObject { ["id"] = $"{prefix}/{agent.Id}", ["type"] = agent.Type, ["name"] = agent.Name, ["description"] = agent.Description, ["framework"] = agent.Framework, ["_original_id"] = agent.Id, ["_backend"] = prefix }); } continue; } // Fallback: query backend /v1/entities for discovery if (!backends.TryGetValue(prefix, out var baseUrl)) { continue; } try { var httpClientFactory = context.RequestServices.GetRequiredService(); using var client = httpClientFactory.CreateClient("devui-proxy"); var response = await client.GetAsync( new Uri(new Uri(baseUrl), "/v1/entities"), context.RequestAborted).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { this._logger.LogWarning( "Failed to fetch entities from backend '{Prefix}' at {Url}: {Status}", prefix, baseUrl, response.StatusCode); continue; } var json = await response.Content.ReadAsStringAsync(context.RequestAborted).ConfigureAwait(false); var doc = JsonNode.Parse(json); var entities = doc?["entities"]?.AsArray(); if (entities is null) { continue; } foreach (var entity in entities) { if (entity is null) { continue; } var cloned = entity.DeepClone(); var id = cloned["id"]?.GetValue() ?? cloned["name"]?.GetValue(); if (id is not null) { cloned["id"] = $"{prefix}/{id}"; cloned["_original_id"] = id; cloned["_backend"] = prefix; } allEntities.Add(cloned); } } catch (Exception ex) when (ex is not OperationCanceledException) { this._logger.LogWarning(ex, "Error fetching entities from backend '{Prefix}' at {Url}", prefix, baseUrl); } } return Results.Json(new { entities = allEntities }); } private async Task RouteEntityInfoAsync(HttpContext context, string entityPath) { var (backendUrl, actualPath) = this.ResolveBackend(entityPath); if (backendUrl is null) { context.Response.StatusCode = StatusCodes.Status404NotFound; return; } var httpClientFactory = context.RequestServices.GetRequiredService(); using var client = httpClientFactory.CreateClient("devui-proxy"); var targetUrl = new Uri(new Uri(backendUrl), $"/v1/entities/{actualPath}"); using var response = await client.GetAsync(targetUrl, context.RequestAborted).ConfigureAwait(false); await CopyResponseAsync(response, context).ConfigureAwait(false); } private async Task RouteResponsesAsync(HttpContext context) { var bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); var json = JsonNode.Parse(bodyBytes); var entityId = json?["metadata"]?["entity_id"]?.GetValue(); if (entityId is null) { var firstBackend = this.ResolveBackends().Values.FirstOrDefault(); if (firstBackend is null) { context.Response.StatusCode = StatusCodes.Status502BadGateway; return; } await ProxyRequestAsync(context, firstBackend, "/v1/responses", bodyBytes).ConfigureAwait(false); return; } var (backendUrl, actualEntityId) = this.ResolveBackend(entityId); if (backendUrl is null) { context.Response.StatusCode = StatusCodes.Status404NotFound; await context.Response.WriteAsJsonAsync( new { error = $"No backend found for entity '{entityId}'" }, context.RequestAborted).ConfigureAwait(false); return; } // Rewrite entity_id to the un-prefixed original value json!["metadata"]!["entity_id"] = actualEntityId; var rewrittenBody = JsonSerializer.SerializeToUtf8Bytes(json); await ProxyRequestAsync(context, backendUrl, "/v1/responses", rewrittenBody, streaming: true).ConfigureAwait(false); } private async Task ProxyConversationsAsync(HttpContext context, string? path) { // Try to determine the backend from agent_id query param or request body string? backendUrl = null; string? actualAgentId = null; var agentId = context.Request.Query["agent_id"].FirstOrDefault(); if (agentId is not null) { (backendUrl, actualAgentId) = this.ResolveBackend(agentId); } // Build query string with rewritten agent_id if we resolved from query param var queryString = (agentId is not null && actualAgentId is not null) ? RewriteAgentIdInQueryString(context.Request.QueryString, actualAgentId) : context.Request.QueryString.ToString(); // Try conversation→backend map for previously-seen conversations if (backendUrl is null) { var conversationId = ExtractConversationId(path); if (conversationId is not null && this._conversationBackendMap.TryGetValue(conversationId, out var mappedUrl)) { backendUrl = mappedUrl; } } // Always read the request body when present so it isn't dropped during proxying byte[]? bodyBytes = null; if (context.Request.ContentLength > 0) { bodyBytes = await ReadRequestBodyAsync(context.Request).ConfigureAwait(false); } // Try to resolve backend from request body metadata when not yet determined if (backendUrl is null && bodyBytes is not null) { var json = JsonNode.Parse(bodyBytes); var entityId = json?["metadata"]?["entity_id"]?.GetValue() ?? json?["metadata"]?["agent_id"]?.GetValue(); if (entityId is not null) { string actualId; (backendUrl, actualId) = this.ResolveBackend(entityId); if (backendUrl is not null) { // Rewrite the entity/agent id to the un-prefixed value if (json?["metadata"]?["entity_id"] is not null) { json!["metadata"]!["entity_id"] = actualId; } if (json?["metadata"]?["agent_id"] is not null) { json!["metadata"]!["agent_id"] = actualId; } bodyBytes = JsonSerializer.SerializeToUtf8Bytes(json); var targetPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; // Also rewrite query string agent_id if present var bodyQueryString = (agentId is not null) ? RewriteAgentIdInQueryString(context.Request.QueryString, actualId) : context.Request.QueryString.ToString(); await this.ProxyAndRecordConversationAsync( context, backendUrl, path, targetPath + bodyQueryString, bodyBytes).ConfigureAwait(false); return; } } // Couldn't determine backend from body; proxy raw bytes to first backend backendUrl = this.ResolveBackends().Values.FirstOrDefault(); if (backendUrl is null) { context.Response.StatusCode = StatusCodes.Status502BadGateway; return; } var targetPathFallback = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; await ProxyRequestAsync( context, backendUrl, targetPathFallback + queryString, bodyBytes).ConfigureAwait(false); return; } // Route to resolved backend (from query or conversation map), or fall back to first backend var backendKnown = backendUrl is not null; backendUrl ??= this.ResolveBackends().Values.FirstOrDefault(); if (backendUrl is null) { context.Response.StatusCode = StatusCodes.Status502BadGateway; return; } var convPath = string.IsNullOrEmpty(path) ? "/v1/conversations" : $"/v1/conversations/{path}"; if (backendKnown) { await this.ProxyAndRecordConversationAsync( context, backendUrl, path, convPath + queryString, bodyBytes).ConfigureAwait(false); } else { await ProxyRequestAsync( context, backendUrl, convPath + queryString, bodyBytes).ConfigureAwait(false); } } /// /// Rewrites the agent_id query parameter to the un-prefixed value for backend routing. /// internal static string RewriteAgentIdInQueryString(QueryString queryString, string actualAgentId) { if (!queryString.HasValue) { return string.Empty; } var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString.Value); query["agent_id"] = actualAgentId; return QueryString.Create(query).ToString(); } private static string? ExtractConversationId(string? path) { if (string.IsNullOrEmpty(path)) { return null; } var slashIndex = path.IndexOf('/'); return slashIndex > 0 ? path[..slashIndex] : path; } /// /// Records the conversation→backend mapping and proxies the request. /// For creation POSTs (no conversation ID in path), intercepts the response to capture the new ID. /// private async Task ProxyAndRecordConversationAsync( HttpContext context, string backendUrl, string? conversationPath, string targetUrl, byte[]? bodyBytes) { var conversationId = ExtractConversationId(conversationPath); if (conversationId is not null) { // We already know the conversation ID — record and proxy normally this._conversationBackendMap[conversationId] = backendUrl; await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); return; } // Creation POST: intercept response to capture the new conversation ID if (!context.Request.Method.Equals("POST", StringComparison.OrdinalIgnoreCase)) { await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); return; } var originalBody = context.Response.Body; using var buffer = new MemoryStream(); context.Response.Body = buffer; try { await ProxyRequestAsync(context, backendUrl, targetUrl, bodyBytes).ConfigureAwait(false); if (context.Response.StatusCode is >= 200 and < 300) { buffer.Position = 0; try { using var doc = await JsonDocument.ParseAsync( buffer, cancellationToken: context.RequestAborted).ConfigureAwait(false); if (doc.RootElement.TryGetProperty("id", out var idProp) && idProp.ValueKind == JsonValueKind.String) { var createdId = idProp.GetString(); if (createdId is not null) { this._conversationBackendMap[createdId] = backendUrl; this._logger.LogDebug( "Recorded conversation '{ConversationId}' → backend '{BackendUrl}'", createdId, backendUrl); } } } catch { // Best-effort: response may not be parseable JSON } } } finally { context.Response.Body = originalBody; buffer.Position = 0; await buffer.CopyToAsync(originalBody, context.RequestAborted).ConfigureAwait(false); } } private static async Task ProxyRequestAsync( HttpContext context, string backendUrl, string path, byte[]? bodyBytes, bool streaming = false) { var httpClientFactory = context.RequestServices.GetRequiredService(); using var client = httpClientFactory.CreateClient("devui-proxy"); var targetUri = new Uri(new Uri(backendUrl), path); using var request = new HttpRequestMessage(new HttpMethod(context.Request.Method), targetUri); foreach (var header in context.Request.Headers) { if (IsHopByHopHeader(header.Key)) { continue; } request.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); } if (bodyBytes is not null) { request.Content = new ByteArrayContent(bodyBytes); if (context.Request.ContentType is not null) { request.Content.Headers.ContentType = System.Net.Http.Headers.MediaTypeHeaderValue.Parse(context.Request.ContentType); } } var completionOption = streaming ? HttpCompletionOption.ResponseHeadersRead : HttpCompletionOption.ResponseContentRead; using var response = await client.SendAsync( request, completionOption, context.RequestAborted).ConfigureAwait(false); if (streaming && response.Content.Headers.ContentType?.MediaType == "text/event-stream") { context.Response.StatusCode = (int)response.StatusCode; context.Response.ContentType = "text/event-stream"; context.Response.Headers.CacheControl = "no-cache"; using var stream = await response.Content.ReadAsStreamAsync(context.RequestAborted).ConfigureAwait(false); await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false); } else { await CopyResponseAsync(response, context).ConfigureAwait(false); } } private (string? BackendUrl, string ActualPath) ResolveBackend(string prefixedId) { var backends = this.ResolveBackends(); var slashIndex = prefixedId.IndexOf('/'); if (slashIndex > 0) { var prefix = prefixedId[..slashIndex]; var rest = prefixedId[(slashIndex + 1)..]; if (backends.TryGetValue(prefix, out var url)) { return (url, rest); } } // Fallback: check all prefixes foreach (var (prefix, url) in backends) { if (prefixedId.StartsWith(prefix + "/", StringComparison.Ordinal)) { return (url, prefixedId[(prefix.Length + 1)..]); } } return (null, prefixedId); } private static async Task ReadRequestBodyAsync(HttpRequest request) { using var ms = new MemoryStream(); await request.Body.CopyToAsync(ms).ConfigureAwait(false); return ms.ToArray(); } private static async Task CopyResponseAsync(HttpResponseMessage response, HttpContext context) { context.Response.StatusCode = (int)response.StatusCode; foreach (var header in response.Headers.Where(h => !IsHopByHopHeader(h.Key))) { context.Response.Headers[header.Key] = header.Value.ToArray(); } foreach (var header in response.Content.Headers) { context.Response.Headers[header.Key] = header.Value.ToArray(); } await response.Content.CopyToAsync(context.Response.Body).ConfigureAwait(false); } private static bool IsHopByHopHeader(string headerName) { return headerName.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase) || headerName.Equals("Connection", StringComparison.OrdinalIgnoreCase) || headerName.Equals("Keep-Alive", StringComparison.OrdinalIgnoreCase) || headerName.Equals("Host", StringComparison.OrdinalIgnoreCase); } }