有时不需希望在客户端直接对 ai 接口发起请求,通过 api 中间层转发 ai 请求, 在转发时填入真实的服务器地址及 key。
注入服务
要注意的是如果启用了 aspire , 系统会启用一个默认的 http 处理策略,比如超时时间只有 10 秒,而 ai 应该会有长时间的思考时间,所以在注册 httpclient 时要清空原有策略,再设置新策略,直接设置新策略的话,它会进行策略叠加,而不是覆盖。
public static class AiServiceExtentions
{
// 注入服务
public static IServiceCollection AddAiServices(this IServiceCollection services, Action<AiServiceOptions> configure)
{
services.Configure(configure);
return services.AddAiServicesCore();
}
// 注入服务
public static IServiceCollection AddAiServices(this IServiceCollection services, IConfiguration configuration)
{
services.Configure<AiServiceOptions>(configuration.GetSection("AiServices"));
return services.AddAiServicesCore();
}
/// <summary>
/// Remove already configured resilience handlers
/// https://github.com/dotnet/extensions/issues/4814#issuecomment-2374345866
/// </summary>
/// <param name="builder">The builder instance.</param>
/// <returns>The value of <paramref name="builder" />.</returns>
public static IHttpClientBuilder ClearResilienceHandlers(this IHttpClientBuilder builder)
{
builder.ConfigureAdditionalHttpMessageHandlers(static (handlers, _) =>
{
for (var i = 0; i < handlers.Count;)
{
if (handlers[i] is ResilienceHandler)
{
handlers.RemoveAt(i);
continue;
}
i++;
}
});
return builder;
}
public static RouteGroupBuilder MapToAiServiceWebApi(this RouteGroupBuilder group)
{
group.MapGet("/test/", (ClaimsPrincipal user) => new
{
name = user.Identity?.Name,
IsAuthenticated = user.Identity?.IsAuthenticated,
keboouser = user.IsInRole("app_keboo_users"),
});
// 处理 ai 的转发
group.MapPost("/v1/chat/completions", (AiService ai, HttpContext context) => ai.ChatCompletionAsync(context));
return group;
}
// 注入服务
private static IServiceCollection AddAiServicesCore(this IServiceCollection services)
{
// 设置 http 处理的重试策略, 当使用 aspire 时,会启动一个默认策略(AddStandardResilienceHandler)
// 只在 10 秒超时时间,要清空原策略,再设置自己的策略
services.AddHttpClient<AiService>(it => it.Timeout = TimeSpan.FromMinutes(5))
.ClearResilienceHandlers()
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromMinutes(3);
options.TotalRequestTimeout.Timeout = TimeSpan.FromMinutes(15);
options.CircuitBreaker.SamplingDuration = TimeSpan.FromMinutes(7);
});
// 注入参数,并进行验证
services.AddOptions<AiServiceOptions>()
.ValidateDataAnnotations() // 使用类注释进行验证
.Validate(it => !string.IsNullOrWhiteSpace(it.ApiKey), "未设置 apiKey")
.Validate(it => !string.IsNullOrWhiteSpace(it.Host), "未设置 host")
.Validate(it => it.Host?.StartsWith("https://", StringComparison.OrdinalIgnoreCase) == true, "必须使用 Https")
.PostConfigure(it =>
{
// 最后的保底的参数设置
if (!string.IsNullOrWhiteSpace(it.Host) && !it.Host.EndsWith("/", StringComparison.OrdinalIgnoreCase))
{
it.Host += "/";
}
})
.ValidateOnStart(); // 指定在启动时进行验证,而不用等到程序运行时进行验证
return services;
}
}
服务转发
public class AiService(HttpClient httpClient, IOptions<AiServiceOptions> options, ILogger<AiService> logger)
{
/// <summary>
/// 中转请求
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public async Task<IResult> ChatCompletionAsync(HttpContext context)
{
var host = options.Value.Host;
var apiKey = options.Value.ApiKey;
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(apiKey))
{
throw new ArgumentException("未定义 ai host 或 apkey");
}
using var ts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
var cancellationToken = ts.Token;
try
{
var uri = new Uri(new Uri(host), "chat/completions");
context.Request.EnableBuffering();
#if DEBUG
// 记录提交数据
context.Request.Body.Position = 0;
using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
var bodyContent = await reader.ReadToEndAsync(cancellationToken);
logger.LogInformation("转发的 Body 内容: {Body}", bodyContent);
#endif
context.Request.Body.Position = 0;
using var ms = new MemoryStream();
await context.Request.Body.CopyToAsync(ms, cancellationToken);
var bytes = ms.ToArray();
var requestMessage = new HttpRequestMessage(HttpMethod.Post, uri)
{
Content = new ByteArrayContent(bytes),
};
// 复制原始请求的 Content-Type (通常是 application/json)
var headerValue = MediaTypeHeaderValue.Parse(context.Request.ContentType ?? "application/json");
requestMessage.Content.Headers.ContentType = headerValue;
requestMessage.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
// 4. 发送并处理流式响应 (Stream)
var response = await httpClient.SendAsync(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
// 将状态码和 Header 转发回手机端
context.Response.StatusCode = (int)response.StatusCode;
foreach (var header in response.Headers)
{
// 流式传输严禁发送 Content-Length 和 Transfer-Encoding
if (header.Key.Equals("Content-Length", StringComparison.OrdinalIgnoreCase) ||
header.Key.Equals("Transfer-Encoding", StringComparison.OrdinalIgnoreCase))
{
continue;
}
context.Response.Headers[header.Key] = header.Value.ToArray();
}
// 7. 显式设置流式 Content-Type
context.Response.ContentType = "text/event-stream; charset=utf-8";
context.Response.Headers.CacheControl = "no-cache";
context.Response.Headers.Connection = "keep-alive";
// 8. 核心:将响应流直接拷贝到输出流
await response.Content.CopyToAsync(context.Response.Body, cancellationToken);
await context.Response.Body.FlushAsync(cancellationToken);
return Results.Empty;
}
catch (Exception exception)
{
logger.LogError(exception, "处理 ai 请求失败");
throw;
}
}
}