节点在线、应用在线、配置在线使用令牌查询
大石头 authored at 2021-12-16 19:49:30
8.43 KiB
Stardust
using System;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.TestHost;
using NewLife;
using NewLife.Security;
using ServerTest.Services;
using Stardust.Data;
using Stardust.Data.Platform;
using Xunit;

namespace ServerTest.Controllers;

/// <summary>OpenAI 兼容对话接口端到端测试。用本地上游桩验证 snake_case 绑定、令牌鉴权与响应结构</summary>
[Collection(StarTestServerCollection.Name)]
public class OpenAIChatEndpointTests : StarTestBase
{
    public OpenAIChatEndpointTests(StarTestServer fixture) : base(fixture) { }

    private static Int32 GetFreePort()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        listener.Stop();

        return port;
    }

    private static String ExtractToken(String body)
    {
        var m = Regex.Match(body, "\"token\"\\s*:\\s*\"([^\"]+)\"");
        return m.Success ? m.Groups[1].Value : null;
    }

    private const String ChatResponse = """{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"stub-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}""";

    private const String StreamResponse = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"stub-model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}]}\n\n" +
        "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"stub-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"你\"}}]}\n\n" +
        "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"stub-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n" +
        "data: [DONE]\n\n";

    /// <summary>启动本地上游桩,返回接收到的请求体</summary>
    private static async Task<(String prefix, Func<String> body, Action stop)> StartUpstream(String response, String contentType)
    {
        var port = GetFreePort();
        var prefix = $"http://127.0.0.1:{port}";

        var listener = new HttpListener();
        listener.Prefixes.Add(prefix + "/");
        listener.Start();

        var received = "";
        var stop = new CancellationTokenSource(TimeSpan.FromSeconds(20));
        var task = Task.Run(async () =>
        {
            while (!stop.IsCancellationRequested)
            {
                HttpListenerContext context;
                try { context = await listener.GetContextAsync(); }
                catch { break; }

                using (var reader = new StreamReader(context.Request.InputStream, Encoding.UTF8)) received = await reader.ReadToEndAsync();

                var buffer = Encoding.UTF8.GetBytes(response);
                context.Response.StatusCode = 200;
                context.Response.ContentType = contentType;
                context.Response.ContentLength64 = buffer.Length;
                await context.Response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
                context.Response.Close();
            }
        }, stop.Token);

        return (prefix, () => received, () =>
        {
            stop.Cancel();
            listener.Stop();
            try { task.Wait(1000); } catch { }
            stop.Dispose();
        });
    }

    private static async Task<(HttpClient client, App app)> LoginAsync(TestServer server, String name, Int32 projectId = 0)
    {
        var app = new App
        {
            Name = name,
            Secret = "secret-" + Rand.NextString(8),
            Enable = true,
            ProjectId = projectId,
            DisplayName = "码神",
            Category = "Web",
        };
        app.Insert();

        var client = server.CreateClient();
        var resp = await client.PostAsync("app/login", new StringContent($"{{\"AppId\":\"{name}\",\"Secret\":\"{app.Secret}\"}}", Encoding.UTF8, "application/json"));
        var token = ExtractToken(await resp.Content.ReadAsStringAsync());
        Assert.False(token.IsNullOrEmpty(), "登录应下发令牌");

        client.DefaultRequestHeaders.Authorization = new("Bearer", token);

        return (client, app);
    }

    [Fact(DisplayName = "OpenAI接口按snake_case绑定请求并返回chat.completion")]
    public async Task ChatCompletions_NonStreaming_ReturnsCompletion()
    {
        var upstream = await StartUpstream(ChatResponse, "application/json");
        var name = "ai_" + Rand.NextString(8);
        var project = new GalaxyProject { Name = "team_" + Rand.NextString(8), Enable = true };
        project.Insert();
        App app = null;
        try
        {
            using var scope = new AISettingScope(s =>
            {
                s.EnableAI = true;
                s.AIUrl = upstream.prefix;
                s.AIKey = "sk-test";
                s.AIModels = "stub-model";
                s.AISystemPrompt = "";
            });

            var (client, entity) = await LoginAsync(_server, name, project.Id);
            app = entity;

            var payload = """{"model":"stub-model","messages":[{"role":"user","content":"hi"}],"temperature":0.2,"max_tokens":64}""";
            var resp = await client.PostAsync("v1/chat/completions", new StringContent(payload, Encoding.UTF8, "application/json"));
            var body = await resp.Content.ReadAsStringAsync();

            Assert.True(resp.IsSuccessStatusCode, "调用应成功:" + body);
            Assert.StartsWith("{\"id\":\"chatcmpl-", body);
            Assert.Contains("\"object\":\"chat.completion\"", body);
            Assert.Contains("\"content\":\"pong\"", body);
            Assert.Contains("\"finish_reason\":\"stop\"", body);
            Assert.Contains("\"total_tokens\":4", body);

            // 请求体按 OpenAI 协议原样转发,且透传调用方应用标识
            var sent = upstream.body();
            Assert.Contains("\"model\":\"stub-model\"", sent);
            Assert.Contains("\"max_tokens\":64", sent);
            Assert.Contains("\"temperature\":0.2", sent);
            Assert.Contains("\"content\":\"hi\"", sent);
            Assert.Contains("\"user\":\"" + name + "\"", sent);
            Assert.Contains("\"stream\":false", sent);

            // 注入的平台上下文系统消息带上应用与团队信息
            Assert.Contains("\"role\":\"system\"", sent);
            Assert.Contains("- Application: 码神 (" + name + "), category Web", sent);
            Assert.Contains("- Team: " + project.Name, sent);
        }
        finally
        {
            upstream.stop();
            app?.Delete();
            project.Delete();
        }
    }

    [Fact(DisplayName = "OpenAI接口在stream=true时返回chat.completion.chunk流")]
    public async Task ChatCompletions_Streaming_ReturnsChunks()
    {
        var upstream = await StartUpstream(StreamResponse, "text/event-stream");
        var name = "ai_" + Rand.NextString(8);
        App app = null;
        try
        {
            using var scope = new AISettingScope(s =>
            {
                s.EnableAI = true;
                s.AIUrl = upstream.prefix;
                s.AIKey = "sk-test";
                s.AIModels = "stub-model";
                s.AISystemPrompt = "";
            });

            var (client, entity) = await LoginAsync(_server, name);
            app = entity;

            var payload = """{"model":"stub-model","messages":[{"role":"user","content":"hi"}],"stream":true}""";
            var resp = await client.PostAsync("v1/chat/completions", new StringContent(payload, Encoding.UTF8, "application/json"));
            var body = await resp.Content.ReadAsStringAsync();

            Assert.True(resp.IsSuccessStatusCode, "调用应成功:" + body);
            Assert.Contains("text/event-stream", resp.Content.Headers.ContentType + "");
            Assert.Contains("chat.completion.chunk", body);
            Assert.Contains("\"delta\":{\"role\":\"assistant\"}", body);
            Assert.Contains("\"content\":\"你\"", body);
            Assert.Contains("\"finish_reason\":\"stop\"", body);
            Assert.Contains("data: [DONE]", body);

            Assert.Contains("\"stream\":true", upstream.body());
        }
        finally
        {
            upstream.stop();
            app?.Delete();
        }
    }
}