Skip to content

Commit aac9c4b

Browse files
committed
feat: add management API and bearer auth
1 parent fcb0612 commit aac9c4b

4 files changed

Lines changed: 334 additions & 28 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
using System.Security.Cryptography;
2+
using System.Text;
3+
using System.Text.Json;
4+
using Microsoft.AspNetCore.Http;
5+
6+
namespace AiRouter.AspNetCore;
7+
8+
internal static class BearerKeyAuthorizer
9+
{
10+
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
11+
12+
public static bool IsAuthorized(HttpContext context, string? expectedKey)
13+
{
14+
if (string.IsNullOrEmpty(expectedKey))
15+
return true;
16+
17+
var header = context.Request.Headers.Authorization.ToString();
18+
const string prefix = "Bearer ";
19+
if (!header.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
20+
return false;
21+
22+
var supplied = header[prefix.Length..].Trim();
23+
if (supplied.Length == 0)
24+
return false;
25+
26+
var expectedHash = SHA256.HashData(Encoding.UTF8.GetBytes(expectedKey));
27+
var suppliedHash = SHA256.HashData(Encoding.UTF8.GetBytes(supplied));
28+
return CryptographicOperations.FixedTimeEquals(expectedHash, suppliedHash);
29+
}
30+
31+
public static async Task<bool> RequireAsync(HttpContext context, string? expectedKey)
32+
{
33+
if (IsAuthorized(context, expectedKey))
34+
return true;
35+
36+
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
37+
context.Response.Headers.WWWAuthenticate = "Bearer";
38+
context.Response.ContentType = "application/json";
39+
await context.Response.WriteAsync(
40+
JsonSerializer.Serialize(new
41+
{
42+
error = new
43+
{
44+
message = "Invalid or missing API key.",
45+
type = "invalid_request_error",
46+
param = (string?)null,
47+
code = "invalid_api_key"
48+
}
49+
}, Json),
50+
context.RequestAborted).ConfigureAwait(false);
51+
return false;
52+
}
53+
}

src/AiRouter.AspNetCore/EndpointRouteBuilderExtensions.cs

Lines changed: 38 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Text.Json;
2+
using AiRouter.AspNetCore;
23
using AiRouter.Providers;
34
using AiRouter.Routing;
45
using Microsoft.AspNetCore.Http;
@@ -8,31 +9,47 @@ namespace Microsoft.AspNetCore.Builder;
89

910
public static class AiRouterEndpointRouteBuilderExtensions
1011
{
11-
public static IEndpointRouteBuilder MapAiRouterOpenAiEndpoints(this IEndpointRouteBuilder endpoints)
12+
public static IEndpointRouteBuilder MapAiRouterOpenAiEndpoints(
13+
this IEndpointRouteBuilder endpoints,
14+
string? bearerKey = null)
1215
{
1316
ArgumentNullException.ThrowIfNull(endpoints);
1417

15-
endpoints.MapPost("/v1/chat/completions", OpenAiEndpointHandlers.ChatAsync);
16-
endpoints.MapPost("/v1/responses", OpenAiEndpointHandlers.ResponsesAsync);
17-
endpoints.MapGet("/v1/models", OpenAiEndpointHandlers.ModelsAsync);
18+
endpoints.MapPost("/v1/chat/completions", (HttpContext context, IAiRouter router) =>
19+
OpenAiEndpointHandlers.ChatAsync(context, router, bearerKey));
20+
endpoints.MapPost("/v1/responses", (HttpContext context, IAiRouter router) =>
21+
OpenAiEndpointHandlers.ResponsesAsync(context, router, bearerKey));
22+
endpoints.MapGet("/v1/models", (HttpContext context, IProviderManager providers, IRouteStore routes) =>
23+
OpenAiEndpointHandlers.ModelsAsync(context, providers, routes, bearerKey));
1824

1925
return endpoints;
2026
}
2127
}
2228

2329
internal static class OpenAiEndpointHandlers
2430
{
25-
public static Task ChatAsync(HttpContext context, IAiRouter router) =>
26-
RouteAsync(context, router.ChatAsync);
31+
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
2732

28-
public static Task ResponsesAsync(HttpContext context, IAiRouter router) =>
29-
RouteAsync(context, router.ResponsesAsync);
33+
public static async Task ChatAsync(HttpContext context, IAiRouter router, string? bearerKey)
34+
{
35+
if (!await BearerKeyAuthorizer.RequireAsync(context, bearerKey).ConfigureAwait(false)) return;
36+
await RouteAsync(context, router.ChatAsync).ConfigureAwait(false);
37+
}
38+
39+
public static async Task ResponsesAsync(HttpContext context, IAiRouter router, string? bearerKey)
40+
{
41+
if (!await BearerKeyAuthorizer.RequireAsync(context, bearerKey).ConfigureAwait(false)) return;
42+
await RouteAsync(context, router.ResponsesAsync).ConfigureAwait(false);
43+
}
3044

3145
public static async Task ModelsAsync(
3246
HttpContext context,
3347
IProviderManager providerManager,
34-
IRouteStore routeStore)
48+
IRouteStore routeStore,
49+
string? bearerKey)
3550
{
51+
if (!await BearerKeyAuthorizer.RequireAsync(context, bearerKey).ConfigureAwait(false)) return;
52+
3653
var ids = new HashSet<string>(StringComparer.Ordinal);
3754
var routes = await routeStore.ListAsync(context.RequestAborted).ConfigureAwait(false);
3855
foreach (var route in routes.Where(static route => route.Enabled))
@@ -80,11 +97,7 @@ public static async Task ModelsAsync(
8097
})
8198
.ToArray();
8299

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);
100+
await WriteJsonAsync(context, StatusCodes.Status200OK, new { @object = "list", data }).ConfigureAwait(false);
88101
}
89102

90103
private static async Task RouteAsync(
@@ -118,9 +131,7 @@ await WriteErrorAsync(context, StatusCodes.Status400BadRequest, "Model is requir
118131
}
119132

120133
var model = modelElement.GetString()!;
121-
var stream = root.TryGetProperty("stream", out var streamElement) &&
122-
streamElement.ValueKind == JsonValueKind.True;
123-
134+
var stream = root.TryGetProperty("stream", out var streamElement) && streamElement.ValueKind == JsonValueKind.True;
124135
var result = await send(model, root.Clone(), stream, context.RequestAborted).ConfigureAwait(false);
125136
await WriteRouterResultAsync(context, result).ConfigureAwait(false);
126137
}
@@ -136,7 +147,6 @@ private static async Task WriteRouterResultAsync(HttpContext context, RouterResu
136147
ProviderFailureKind.RateLimited => "rate_limit_error",
137148
_ => "server_error"
138149
};
139-
140150
await WriteErrorAsync(
141151
context,
142152
result.StatusCode > 0 ? result.StatusCode : StatusCodes.Status500InternalServerError,
@@ -146,10 +156,8 @@ await WriteErrorAsync(
146156
}
147157

148158
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;
159+
if (!string.IsNullOrWhiteSpace(result.ProviderId)) context.Response.Headers["X-AiRouter-Provider"] = result.ProviderId;
160+
if (!string.IsNullOrWhiteSpace(result.Model)) context.Response.Headers["X-AiRouter-Model"] = result.Model;
153161

154162
if (result.Stream is not null)
155163
{
@@ -164,11 +172,8 @@ await WriteErrorAsync(
164172
await context.Response.WriteAsync(body.GetRawText(), context.RequestAborted).ConfigureAwait(false);
165173
}
166174

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
175+
private static Task WriteErrorAsync(HttpContext context, int statusCode, string message, string type) =>
176+
WriteJsonAsync(context, statusCode, new
172177
{
173178
error = new
174179
{
@@ -178,6 +183,11 @@ private static async Task WriteErrorAsync(HttpContext context, int statusCode, s
178183
code = (string?)null
179184
}
180185
});
181-
await context.Response.WriteAsync(payload, context.RequestAborted).ConfigureAwait(false);
186+
187+
private static async Task WriteJsonAsync(HttpContext context, int statusCode, object value)
188+
{
189+
context.Response.StatusCode = statusCode;
190+
context.Response.ContentType = "application/json";
191+
await context.Response.WriteAsync(JsonSerializer.Serialize(value, Json), context.RequestAborted).ConfigureAwait(false);
182192
}
183193
}

0 commit comments

Comments
 (0)