Skip to content

Commit 012c2fc

Browse files
committed
feat: support embedded ASP.NET hosting
1 parent 3695e2c commit 012c2fc

2 files changed

Lines changed: 193 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace Microsoft.Extensions.DependencyInjection;
2+
3+
public static class AiRouterAspNetCoreServiceCollectionExtensions
4+
{
5+
public static IServiceCollection AddAiRouterAspNetCore(this IServiceCollection services)
6+
{
7+
ArgumentNullException.ThrowIfNull(services);
8+
return services;
9+
}
10+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
using System.Text.Json;
2+
using AiRouter.Providers;
3+
using AiRouter.Routing;
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Routing;
6+
7+
namespace Microsoft.AspNetCore.Builder;
8+
9+
public static class AiRouterEndpointRouteBuilderExtensions
10+
{
11+
public static IEndpointRouteBuilder MapAiRouterOpenAiEndpoints(this IEndpointRouteBuilder endpoints)
12+
{
13+
ArgumentNullException.ThrowIfNull(endpoints);
14+
15+
endpoints.MapPost("/v1/chat/completions", OpenAiEndpointHandlers.ChatAsync);
16+
endpoints.MapPost("/v1/responses", OpenAiEndpointHandlers.ResponsesAsync);
17+
endpoints.MapGet("/v1/models", OpenAiEndpointHandlers.ModelsAsync);
18+
19+
return endpoints;
20+
}
21+
}
22+
23+
internal static class OpenAiEndpointHandlers
24+
{
25+
public static Task ChatAsync(HttpContext context, IAiRouter router) =>
26+
RouteAsync(context, router.ChatAsync);
27+
28+
public static Task ResponsesAsync(HttpContext context, IAiRouter router) =>
29+
RouteAsync(context, router.ResponsesAsync);
30+
31+
public static async Task ModelsAsync(
32+
HttpContext context,
33+
IProviderManager providerManager,
34+
IRouteStore routeStore)
35+
{
36+
var ids = new HashSet<string>(StringComparer.Ordinal);
37+
var routes = await routeStore.ListAsync(context.RequestAborted).ConfigureAwait(false);
38+
foreach (var route in routes.Where(static route => route.Enabled))
39+
ids.Add(route.Id);
40+
41+
var providers = await providerManager.ListAsync(context.RequestAborted).ConfigureAwait(false);
42+
var anyDirectModel = false;
43+
foreach (var provider in providers.Where(static provider => provider.Enabled))
44+
{
45+
IReadOnlyList<string> models = provider.Models ?? [];
46+
if (models.Count == 0 && provider.DiscoverModels)
47+
{
48+
try
49+
{
50+
models = await providerManager.ListModelsAsync(provider.Id, context.RequestAborted).ConfigureAwait(false);
51+
}
52+
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
53+
{
54+
throw;
55+
}
56+
catch
57+
{
58+
models = [];
59+
}
60+
}
61+
62+
foreach (var model in models.Where(static model => !string.IsNullOrWhiteSpace(model)))
63+
{
64+
ids.Add($"{provider.Id}/{model}");
65+
anyDirectModel = true;
66+
}
67+
}
68+
69+
if (anyDirectModel)
70+
ids.Add("all");
71+
72+
var data = ids
73+
.Order(StringComparer.Ordinal)
74+
.Select(static id => new
75+
{
76+
id,
77+
@object = "model",
78+
created = 0,
79+
owned_by = "ai-router"
80+
})
81+
.ToArray();
82+
83+
context.Response.StatusCode = StatusCodes.Status200OK;
84+
context.Response.ContentType = "application/json";
85+
await context.Response.WriteAsync(
86+
JsonSerializer.Serialize(new { @object = "list", data }),
87+
context.RequestAborted).ConfigureAwait(false);
88+
}
89+
90+
private static async Task RouteAsync(
91+
HttpContext context,
92+
Func<string, JsonElement, bool, CancellationToken, Task<RouterResult>> send)
93+
{
94+
JsonDocument document;
95+
try
96+
{
97+
document = await JsonDocument.ParseAsync(context.Request.Body, cancellationToken: context.RequestAborted)
98+
.ConfigureAwait(false);
99+
}
100+
catch (JsonException)
101+
{
102+
await WriteErrorAsync(context, StatusCodes.Status400BadRequest, "Request body must be valid JSON.", "invalid_request_error")
103+
.ConfigureAwait(false);
104+
return;
105+
}
106+
107+
using (document)
108+
{
109+
var root = document.RootElement;
110+
if (root.ValueKind != JsonValueKind.Object ||
111+
!root.TryGetProperty("model", out var modelElement) ||
112+
modelElement.ValueKind != JsonValueKind.String ||
113+
string.IsNullOrWhiteSpace(modelElement.GetString()))
114+
{
115+
await WriteErrorAsync(context, StatusCodes.Status400BadRequest, "Model is required.", "invalid_request_error")
116+
.ConfigureAwait(false);
117+
return;
118+
}
119+
120+
var model = modelElement.GetString()!;
121+
var stream = root.TryGetProperty("stream", out var streamElement) &&
122+
streamElement.ValueKind == JsonValueKind.True;
123+
124+
var result = await send(model, root.Clone(), stream, context.RequestAborted).ConfigureAwait(false);
125+
await WriteRouterResultAsync(context, result).ConfigureAwait(false);
126+
}
127+
}
128+
129+
private static async Task WriteRouterResultAsync(HttpContext context, RouterResult result)
130+
{
131+
if (!result.Success)
132+
{
133+
var type = result.FailureKind switch
134+
{
135+
ProviderFailureKind.InvalidRequest => "invalid_request_error",
136+
ProviderFailureKind.RateLimited => "rate_limit_error",
137+
_ => "server_error"
138+
};
139+
140+
await WriteErrorAsync(
141+
context,
142+
result.StatusCode > 0 ? result.StatusCode : StatusCodes.Status500InternalServerError,
143+
result.ErrorMessage ?? "AI routing request failed.",
144+
type).ConfigureAwait(false);
145+
return;
146+
}
147+
148+
context.Response.StatusCode = result.StatusCode > 0 ? result.StatusCode : StatusCodes.Status200OK;
149+
if (!string.IsNullOrWhiteSpace(result.ProviderId))
150+
context.Response.Headers["X-AiRouter-Provider"] = result.ProviderId;
151+
if (!string.IsNullOrWhiteSpace(result.Model))
152+
context.Response.Headers["X-AiRouter-Model"] = result.Model;
153+
154+
if (result.Stream is not null)
155+
{
156+
context.Response.ContentType = result.ContentType ?? "text/event-stream";
157+
await using var stream = result.Stream;
158+
await stream.CopyToAsync(context.Response.Body, context.RequestAborted).ConfigureAwait(false);
159+
return;
160+
}
161+
162+
context.Response.ContentType = result.ContentType ?? "application/json";
163+
if (result.Body is JsonElement body)
164+
await context.Response.WriteAsync(body.GetRawText(), context.RequestAborted).ConfigureAwait(false);
165+
}
166+
167+
private static async Task WriteErrorAsync(HttpContext context, int statusCode, string message, string type)
168+
{
169+
context.Response.StatusCode = statusCode;
170+
context.Response.ContentType = "application/json";
171+
var payload = JsonSerializer.Serialize(new
172+
{
173+
error = new
174+
{
175+
message,
176+
type,
177+
param = (string?)null,
178+
code = (string?)null
179+
}
180+
});
181+
await context.Response.WriteAsync(payload, context.RequestAborted).ConfigureAwait(false);
182+
}
183+
}

0 commit comments

Comments
 (0)