Commit 33a92537 authored by shRabbit's avatar shRabbit
Browse files

初步实现

parent 80b1a8b6
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -2,5 +2,6 @@
  <Folder Name="/Solution Items/">
    <File Path="compose.yaml" />
  </Folder>
  <Project Path="src/NoCost.Tests/NoCost.Tests.csproj" />
  <Project Path="src/NoCost/NoCost.csproj" />
</Solution>
+106 −0
Original line number Diff line number Diff line
using System.Net;
using Microsoft.Extensions.Configuration;
using NoCost;
using NoCost.Configuration;
using NoCost.Repeaters;

namespace NoCost.Tests;

public class AppConfiguratorTests
{
    private static IConfiguration Build(Action<Dictionary<string, string?>> fill)
    {
        var values = new Dictionary<string, string?>();
        fill(values);
        return new ConfigurationBuilder().AddInMemoryCollection(values).Build();
    }

    [Fact]
    public void 加载拆分后的ModelsRepeaters配置()
    {
        var config = Build(v =>
        {
            v["ModelsConfig:0:Model"] = "m1";
            v["ModelsConfig:0:Repeater"] = "openrouter";
            v["ModelsConfig:0:Modal"] = "Text";
            v["ModelsConfig:0:SmartLevel"] = "5";
            v["RepeaterConfig:0:Name"] = "openrouter";
            v["RepeaterConfig:0:Key"] = "sk-test";
        });

        var models = AppConfigurator.LoadModels(config);
        var repeaters = AppConfigurator.LoadRepeaters(config, _ => new HttpClient(FakeHttpMessageHandler.RespondJson(HttpStatusCode.OK, "{}")));

        var model = models.Map["m1"];
        Assert.Equal("m1", model.Model);
        Assert.Equal("openrouter", model.Repeater);
        Assert.Equal(Modal.Text, model.Modal);
        Assert.Equal(5, model.SmartLevel);

        Assert.True(repeaters.Contains("openrouter"));
        AppConfigurator.Validate(models, repeaters); // 不应抛
    }

    [Fact]
    public void 模型引用未配置转发器_抛出异常()
    {
        var config = Build(v =>
        {
            v["ModelsConfig:0:Model"] = "m1";
            v["ModelsConfig:0:Repeater"] = "ghost";
            v["ModelsConfig:0:Modal"] = "Text";
            v["ModelsConfig:0:SmartLevel"] = "0";
        });

        var models = AppConfigurator.LoadModels(config);
        var repeaters = AppConfigurator.LoadRepeaters(config);

        Assert.Throws<InvalidOperationException>(() => AppConfigurator.Validate(models, repeaters));
    }

    [Fact]
    public void 不支持的转发器类型_抛出异常()
    {
        var config = Build(v =>
        {
            v["RepeaterConfig:0:Name"] = "unknown_provider";
            v["RepeaterConfig:0:Key"] = "sk";
        });

        Assert.Throws<InvalidOperationException>(() => AppConfigurator.LoadRepeaters(config));
    }

    [Fact]
    public void Key支持env前缀引用环境变量()
    {
        Environment.SetEnvironmentVariable("NCOST_TEST_KEY", "from-env");
        try
        {
            var config = Build(v =>
            {
                v["RepeaterConfig:0:Name"] = "openrouter";
                v["RepeaterConfig:0:Key"] = "env:NCOST_TEST_KEY";
            });

            var repeaters = AppConfigurator.LoadRepeaters(config, _ => new HttpClient(FakeHttpMessageHandler.RespondJson(HttpStatusCode.OK, "{}")));

            Assert.True(repeaters.Contains("openrouter"));
        }
        finally
        {
            Environment.SetEnvironmentVariable("NCOST_TEST_KEY", null);
        }
    }

    [Fact]
    public void env引用缺失的环境变量_抛出异常()
    {
        var config = Build(v =>
        {
            v["RepeaterConfig:0:Name"] = "openrouter";
            v["RepeaterConfig:0:Key"] = "env:NCOST_NOT_EXIST_XXXX";
        });

        Assert.Throws<ArgumentException>(() => AppConfigurator.LoadRepeaters(config));
    }
}
+169 −0
Original line number Diff line number Diff line
using System.Text.Json.Nodes;
using NoCost;
using NoCost.Repeaters;
using NoCost.Routing;

namespace NoCost.Tests;

public class ForwardServiceTests
{
    private static ModelItem M(string model, int smart = 0, string repeater = "openrouter") => new()
    {
        Model = model,
        Repeater = repeater,
        Modal = Modal.Text,
        SmartLevel = smart,
    };

    private static RepeaterRegistry Registry(params IRepeater[] repeaters)
    {
        var registry = new RepeaterRegistry();
        foreach (var r in repeaters) registry.Add(r);
        return registry;
    }

    /// <summary>可编程转发器:按模型名决定成功或失败。</summary>
    private sealed class FakeRepeater : IRepeater
    {
        private readonly Func<CompletionRequest, Task<RepeaterResponse>> _handler;
        public string Name { get; }
        public List<string> Requests { get; } = [];

        public FakeRepeater(string name, Func<CompletionRequest, Task<RepeaterResponse>> handler)
        {
            Name = name;
            _handler = handler;
        }

        public Task<RepeaterResponse> CompleteAsync(CompletionRequest request, CancellationToken ct)
        {
            Requests.Add(request.Model);
            return _handler(request);
        }
    }

    private static RepeaterResponse Ok() => new()
    {
        Stream = false,
        Content = JsonNode.Parse("""{"id":"ok","choices":[]}"""),
        TimeToFirstToken = TimeSpan.FromMilliseconds(1),
    };

    [Fact]
    public async Task 自动路由_选择最快模型并成功转发()
    {
        var pool = new ModelPool([M("a"), M("b")], refreshRate: 0.0);
        pool.Record("a", true, TimeSpan.FromMilliseconds(50));
        pool.Record("b", true, TimeSpan.FromMilliseconds(500));

        var repeater = new FakeRepeater("openrouter", _ => Task.FromResult(Ok()));
        var svc = new ForwardService(pool, Registry(repeater));

        var result = await svc.CompleteAsync(
            new CompletionRequest { Model = "", Body = new JsonObject(), Stream = false },
            SelectMode.Fast, null, CancellationToken.None);

        Assert.True(result.Success);
        Assert.Equal("a", result.UsedModel);
        Assert.Equal(["a"], repeater.Requests);
    }

    [Fact]
    public async Task 回退_首选模型失败则依次尝试下一个()
    {
        var pool = new ModelPool([M("a"), M("b")], refreshRate: 0.0);
        var repeater = new FakeRepeater("openrouter", req =>
            req.Model == "a"
                ? throw new HttpRequestException("a 挂了")
                : Task.FromResult(Ok()));
        var svc = new ForwardService(pool, Registry(repeater));

        var result = await svc.CompleteAsync(
            new CompletionRequest { Model = "", Body = new JsonObject(), Stream = false },
            SelectMode.Fast, null, CancellationToken.None);

        Assert.True(result.Success);
        Assert.Equal("b", result.UsedModel);
        Assert.Equal(["a", "b"], repeater.Requests);
        // a 的错误被记录,后续排行会把它往后排
        Assert.True(pool.Get("a")!.ErrorRate > 0);
    }

    [Fact]
    public async Task 全部失败_返回失败结果()
    {
        var pool = new ModelPool([M("a"), M("b")]);
        var repeater = new FakeRepeater("openrouter", _ => throw new HttpRequestException("挂了"));
        var svc = new ForwardService(pool, Registry(repeater));

        var result = await svc.CompleteAsync(
            new CompletionRequest { Model = "", Body = new JsonObject(), Stream = false },
            SelectMode.Fast, null, CancellationToken.None);

        Assert.False(result.Success);
        Assert.NotNull(result.Error);
    }

    [Fact]
    public async Task 指定模型_优先使用指定模型()
    {
        var pool = new ModelPool([M("a"), M("b")]);
        pool.Record("a", true, TimeSpan.FromMilliseconds(10)); // a 最快,但被指定 b
        var repeater = new FakeRepeater("openrouter", _ => Task.FromResult(Ok()));
        var svc = new ForwardService(pool, Registry(repeater));

        var result = await svc.CompleteAsync(
            new CompletionRequest { Model = "b", Body = new JsonObject(), Stream = false },
            SelectMode.Fast, "b", CancellationToken.None);

        Assert.True(result.Success);
        Assert.Equal("b", result.UsedModel);
        Assert.Equal(["b"], repeater.Requests);
    }

    [Fact]
    public async Task 模型指向未注册转发器_跳过并继续()
    {
        var pool = new ModelPool([M("a", repeater: "ghost"), M("b")]);
        var repeater = new FakeRepeater("openrouter", _ => Task.FromResult(Ok()));
        var svc = new ForwardService(pool, Registry(repeater));

        var result = await svc.CompleteAsync(
            new CompletionRequest { Model = "", Body = new JsonObject(), Stream = false },
            SelectMode.Fast, null, CancellationToken.None);

        Assert.True(result.Success);
        Assert.Equal("b", result.UsedModel);
    }

    [Fact]
    public async Task 流式_透传chunk_统计由调用方记录()
    {
        var pool = new ModelPool([M("a")]);
        var repeater = new FakeRepeater("openrouter", _ => Task.FromResult(new RepeaterResponse
        {
            Stream = true,
            TimeToFirstToken = TimeSpan.FromMilliseconds(30),
            Chunks = StaticChunks("""{"choices":[{"delta":{"content":"hi"}}]}"""),
        }));
        var svc = new ForwardService(pool, Registry(repeater));

        var result = await svc.CompleteAsync(
            new CompletionRequest { Model = "", Body = new JsonObject(), Stream = true },
            SelectMode.Fast, null, CancellationToken.None);

        var chunks = new List<JsonNode>();
        await foreach (var c in result.Response!.Chunks!) chunks.Add(c);

        Assert.True(result.Success);
        Assert.Single(chunks);
        // 流式统计由调用方(控制器)在枚举结束后记录,服务本身不记录
        Assert.Equal(0, pool.Get("a")!.SuccessCount);
    }

    private static async IAsyncEnumerable<JsonNode> StaticChunks(string json)
    {
        yield return JsonNode.Parse(json)!;
        await Task.CompletedTask;
    }
}
+135 −0
Original line number Diff line number Diff line
using NoCost;
using NoCost.Routing;

namespace NoCost.Tests;

public class ModelPoolTests
{
    private static ModelItem M(string model, int smart, string repeater = "openrouter") => new()
    {
        Model = model,
        Repeater = repeater,
        Modal = Modal.Text,
        SmartLevel = smart,
    };

    [Fact]
    public void Fast_选择延迟最低的健康模型()
    {
        var pool = new ModelPool([M("a", 0), M("b", 5), M("c", 10)]);
        pool.Record("a", true, TimeSpan.FromMilliseconds(500));
        pool.Record("b", true, TimeSpan.FromMilliseconds(100));
        pool.Record("c", true, TimeSpan.FromMilliseconds(300));

        var ranked = pool.Rank(SelectMode.Fast);

        Assert.Equal("b", ranked[0].Model.Model);
    }

    [Fact]
    public void Fast_错误率高的模型排到后面()
    {
        var pool = new ModelPool([M("a", 0), M("b", 5)]);
        // a 延迟很低但错误率 2/3 > 0.3
        pool.Record("a", true, TimeSpan.FromMilliseconds(50));
        pool.Record("a", false, null);
        pool.Record("a", false, null);
        // b 较慢但健康
        pool.Record("b", true, TimeSpan.FromMilliseconds(200));

        var ranked = pool.Rank(SelectMode.Fast);

        Assert.Equal("b", ranked[0].Model.Model);
    }

    [Fact]
    public void Smart_选择智能等级最高的健康模型()
    {
        var pool = new ModelPool([M("a", 10), M("b", 1), M("c", 5)]);

        var ranked = pool.Rank(SelectMode.Smart);

        Assert.Equal("a", ranked[0].Model.Model);
    }

    [Fact]
    public void Smart_高错误率的不健康模型排到后面()
    {
        var pool = new ModelPool([M("smart", 10), M("dumb", 1)]);
        pool.Record("smart", false, null);
        pool.Record("smart", false, null); // 错误率 1.0 > 0.3

        var ranked = pool.Rank(SelectMode.Smart);

        Assert.Equal("dumb", ranked[0].Model.Model);
    }

    [Fact]
    public void FastSmart_平衡速度与智能_既不选最快也不选最聪明()
    {
        // A: 聪明但慢;C: 快但笨;B: 折中 → FastSmart 应首选 B
        var pool = new ModelPool([M("A", 10), M("B", 6), M("C", 2)]);
        pool.Record("A", true, TimeSpan.FromMilliseconds(600));
        pool.Record("B", true, TimeSpan.FromMilliseconds(200));
        pool.Record("C", true, TimeSpan.FromMilliseconds(100));

        Assert.Equal("A", pool.Rank(SelectMode.Smart)[0].Model.Model);
        Assert.Equal("C", pool.Rank(SelectMode.Fast)[0].Model.Model);
        Assert.Equal("B", pool.Rank(SelectMode.FastSmart)[0].Model.Model);
    }

    [Fact]
    public void Select_刷新概率为1时必然落到非最优模型()
    {
        var pool = new ModelPool([M("a", 0), M("b", 5)], refreshRate: 1.0, random: new Random(42));
        // a 最快 → Fast 排行最优;刷新必然落到非最优 b
        pool.Record("a", true, TimeSpan.FromMilliseconds(50));
        pool.Record("b", true, TimeSpan.FromMilliseconds(500));

        var selected = pool.Select(SelectMode.Fast);

        Assert.Equal("b", selected!.Model);
    }

    [Fact]
    public void Select_刷新概率为0时始终选最优模型()
    {
        var pool = new ModelPool([M("a", 0), M("b", 5)], refreshRate: 0.0);
        pool.Record("a", true, TimeSpan.FromMilliseconds(50));
        pool.Record("b", true, TimeSpan.FromMilliseconds(500));

        var selected = pool.Select(SelectMode.Fast);

        Assert.Equal("a", selected!.Model);
    }

    [Fact]
    public void Record_错误会抬高错误率并影响健康度()
    {
        var pool = new ModelPool([M("a", 0)]);
        pool.Record("a", false, null);
        pool.Record("a", false, null);
        pool.Record("a", false, null);

        var stat = pool.Get("a")!;

        Assert.Equal(0, stat.SuccessCount);
        Assert.Equal(3, stat.ErrorCount);
        Assert.Equal(1.0, stat.ErrorRate, 6);
        Assert.True(stat.ErrorRate > 0.3);
    }

    [Fact]
    public void Record_成功延迟进入滑动平均()
    {
        var pool = new ModelPool([M("a", 0)]);
        pool.Record("a", true, TimeSpan.FromMilliseconds(100));
        pool.Record("a", true, TimeSpan.FromMilliseconds(300));

        var stat = pool.Get("a")!;

        // EMA(α=0.25): 100 * 0.75 + 300 * 0.25 = 150
        Assert.Equal(150, stat.AvgLatencyMs, 6);
        Assert.Equal(2, stat.SuccessCount);
    }
}
+34 −0
Original line number Diff line number Diff line
<Project Sdk="Microsoft.NET.Sdk">

    <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <IsPackable>false</IsPackable>
        <OutputType>Exe</OutputType>
    </PropertyGroup>

    <ItemGroup>
        <FrameworkReference Include="Microsoft.AspNetCore.App"/>
    </ItemGroup>

    <ItemGroup>
        <ProjectReference Include="..\NoCost\NoCost.csproj"/>
    </ItemGroup>

    <ItemGroup>
        <PackageReference Include="JetBrains.Annotations" Version="2026.2.0"/>
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
        <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
          <PrivateAssets>all</PrivateAssets>
          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
        </PackageReference>
        <!-- xunit.v3 走 Microsoft.Testing.Platform;不再需要 Microsoft.NET.Test.Sdk / xunit.runner.visualstudio -->
        <PackageReference Include="xunit.v3" Version="4.0.0"/>
    </ItemGroup>

    <ItemGroup>
        <Using Include="Xunit"/>
    </ItemGroup>

</Project>
Loading