asp.net core 转发客户端 ai 调用

有时不需希望在客户端直接对 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;
        }
    }
}
上一篇
下一篇