Commit b83feb79 authored by shRabbit's avatar shRabbit
Browse files

新增隐私模式(PrivacyMode 阈值过滤)

- PrivacyMode 枚举:Safe/CollectOnly/TrainOnly/Both,替换原 Unsafe bool

- 路由只命中 PrivacyMode <= 请求阈值的模型(含随机刷新与显式指定)

- 设置来源:Authorization JSON + 自定义头 X-Private-Mode(头优先)

- SelectModeParser 重构为 RouteOptionsParser
parent def7ae86
Loading
Loading
Loading
Loading
Loading
+22 −0
Original line number Diff line number Diff line
@@ -103,4 +103,26 @@ public class AppConfiguratorTests

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

    [Fact]
    public void 加载PrivacyMode标记()
    {
        var config = Build(v =>
        {
            v["ModelsConfig:0:Model"] = "both";
            v["ModelsConfig:0:Repeater"] = "openrouter";
            v["ModelsConfig:0:Modal"] = "Text";
            v["ModelsConfig:0:SmartLevel"] = "0";
            v["ModelsConfig:0:PrivacyMode"] = "Both";
            v["ModelsConfig:1:Model"] = "default";
            v["ModelsConfig:1:Repeater"] = "openrouter";
            v["ModelsConfig:1:Modal"] = "Text";
            v["ModelsConfig:1:SmartLevel"] = "0";
        });

        var models = AppConfigurator.LoadModels(config);

        Assert.Equal(PrivacyMode.Both, models.Map["both"].PrivacyMode);
        Assert.Equal(PrivacyMode.Safe, models.Map["default"].PrivacyMode); // 缺省 Safe
    }
}
+46 −7
Original line number Diff line number Diff line
@@ -7,12 +7,13 @@ namespace NoCost.Tests;

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

    private static RepeaterRegistry Registry(params IRepeater[] repeaters)
@@ -61,7 +62,7 @@ public class ForwardServiceTests

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

        Assert.True(result.Success);
        Assert.Equal("a", result.UsedModel);
@@ -80,7 +81,7 @@ public class ForwardServiceTests

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

        Assert.True(result.Success);
        Assert.Equal("b", result.UsedModel);
@@ -98,7 +99,7 @@ public class ForwardServiceTests

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

        Assert.False(result.Success);
        Assert.NotNull(result.Error);
@@ -114,7 +115,7 @@ public class ForwardServiceTests

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

        Assert.True(result.Success);
        Assert.Equal("b", result.UsedModel);
@@ -130,7 +131,7 @@ public class ForwardServiceTests

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

        Assert.True(result.Success);
        Assert.Equal("b", result.UsedModel);
@@ -150,7 +151,7 @@ public class ForwardServiceTests

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

        var chunks = new List<JsonNode>();
        await foreach (var c in result.Response!.Chunks!) chunks.Add(c);
@@ -161,6 +162,44 @@ public class ForwardServiceTests
        Assert.Equal(0, pool.Get("a")!.SuccessCount);
    }

    [Fact]
    public async Task 隐私阈值_超过阈值的模型不作为候选()
    {
        var pool = new ModelPool([
            M("both", 0, privacy: PrivacyMode.Both),
            M("safe", 0),
        ], refreshRate: 0.0);
        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 },
            new RouteOptions { Mode = SelectMode.Fast, Privacy = PrivacyMode.Safe }, null, CancellationToken.None);

        Assert.True(result.Success);
        Assert.Equal("safe", result.UsedModel);
        Assert.Equal(["safe"], repeater.Requests); // both 从未被尝试
    }

    [Fact]
    public async Task 隐私阈值_显式指定越界模型也不命中()
    {
        var pool = new ModelPool([
            M("both", 0, privacy: PrivacyMode.Both),
            M("safe", 0),
        ], refreshRate: 0.0);
        var repeater = new FakeRepeater("openrouter", _ => Task.FromResult(Ok()));
        var svc = new ForwardService(pool, Registry(repeater));

        var result = await svc.CompleteAsync(
            new CompletionRequest { Model = "both", Body = new JsonObject(), Stream = false },
            new RouteOptions { Mode = SelectMode.Fast, Privacy = PrivacyMode.Safe }, "both", CancellationToken.None);

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

    private static async IAsyncEnumerable<JsonNode> StaticChunks(string json)
    {
        yield return JsonNode.Parse(json)!;
+68 −1
Original line number Diff line number Diff line
@@ -5,12 +5,13 @@ namespace NoCost.Tests;

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

    [Fact]
@@ -132,4 +133,70 @@ public class ModelPoolTests
        Assert.Equal(150, stat.AvgLatencyMs, 6);
        Assert.Equal(2, stat.SuccessCount);
    }

    [Fact]
    public void 隐私阈值_Safe下仅含Safe模型()
    {
        var pool = new ModelPool([
            M("both", 10, privacy: PrivacyMode.Both),
            M("collect", 5, privacy: PrivacyMode.CollectOnly),
            M("safe", 1),
        ]);
        pool.Record("both", true, TimeSpan.FromMilliseconds(10));   // 最快但不安全
        pool.Record("collect", true, TimeSpan.FromMilliseconds(20));
        pool.Record("safe", true, TimeSpan.FromMilliseconds(500));

        var ranked = pool.Rank(SelectMode.Fast, privacy: PrivacyMode.Safe);

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

    [Fact]
    public void 隐私阈值_CollectOnlySafeCollectOnly()
    {
        var pool = new ModelPool([
            M("both", 0, privacy: PrivacyMode.Both),
            M("collect", 0, privacy: PrivacyMode.CollectOnly),
            M("safe", 0),
        ]);

        var ranked = pool.Rank(SelectMode.Fast, privacy: PrivacyMode.CollectOnly);

        Assert.Equal(2, ranked.Count);
        Assert.DoesNotContain(ranked, s => s.Model.Model == "both");
    }

    [Fact]
    public void 隐私阈值_默认Both不限制()
    {
        var pool = new ModelPool([
            M("both", 0, privacy: PrivacyMode.Both),
            M("safe", 0),
        ]);

        Assert.Equal(2, pool.Rank(SelectMode.Fast).Count);
    }

    [Fact]
    public void 隐私阈值_随机刷新也不命中越界模型()
    {
        var pool = new ModelPool([
            M("both", 0, privacy: PrivacyMode.Both),
            M("safe1", 0),
            M("safe2", 0),
        ], refreshRate: 1.0, random: new Random(1));

        for (var i = 0; i < 20; i++)
            Assert.NotEqual("both", pool.Select(SelectMode.Fast, privacy: PrivacyMode.Safe)!.Model);
    }

    [Fact]
    public void 隐私阈值_无符合候选_返回空()
    {
        var pool = new ModelPool([M("both", 0, privacy: PrivacyMode.Both)]);

        Assert.Null(pool.Select(SelectMode.Fast, privacy: PrivacyMode.Safe));
        Assert.Empty(pool.Rank(SelectMode.Fast, privacy: PrivacyMode.Safe));
    }
}
+60 −0
Original line number Diff line number Diff line
using NoCost;

namespace NoCost.Tests;

public class RouteOptionsParserTests
{
    [Theory]
    [InlineData("Bearer {\"selectmode\":\"fast\"}", null, SelectMode.Fast, PrivacyMode.Both)]
    [InlineData("Bearer {\"selectmode\":\"smart\"}", null, SelectMode.Smart, PrivacyMode.Both)]
    [InlineData("Bearer {\"selectmode\":\"fast-smart\"}", null, SelectMode.FastSmart, PrivacyMode.Both)]
    [InlineData("Bearer {\"selectmode\":\"smart-fast\"}", null, SelectMode.FastSmart, PrivacyMode.Both)]
    [InlineData("Bearer {\"selectmode\":\"fast\",\"privacy\":\"safe\"}", null, SelectMode.Fast, PrivacyMode.Safe)]
    [InlineData("Bearer {\"selectmode\":\"smart\",\"privacy\":\"collect-only\"}", null, SelectMode.Smart, PrivacyMode.CollectOnly)]
    [InlineData("Bearer {\"selectmode\":\"fast\",\"privacy\":true}", null, SelectMode.Fast, PrivacyMode.Safe)]
    [InlineData("Bearer {\"selectmode\":\"fast\",\"privacy\":false}", null, SelectMode.Fast, PrivacyMode.Both)]
    [InlineData("Bearer {\"selectmode\":\"unknown\"}", null, SelectMode.Fast, PrivacyMode.Both)]
    [InlineData("Bearer arbitrary-key", null, SelectMode.Fast, PrivacyMode.Both)]
    [InlineData("Bearer ", null, SelectMode.Fast, PrivacyMode.Both)]
    [InlineData("", null, SelectMode.Fast, PrivacyMode.Both)]
    [InlineData(null, null, SelectMode.Fast, PrivacyMode.Both)]
    public void Parse_模式与隐私从authorization解析(string? auth, string? header, SelectMode mode, PrivacyMode privacy)
    {
        var options = RouteOptionsParser.Parse(auth, header);

        Assert.Equal(mode, options.Mode);
        Assert.Equal(privacy, options.Privacy);
    }

    [Theory]
    [InlineData(null, "safe", PrivacyMode.Safe)]
    [InlineData(null, "on", PrivacyMode.Safe)]
    [InlineData(null, "1", PrivacyMode.Safe)]
    [InlineData(null, "collect-only", PrivacyMode.CollectOnly)]
    [InlineData(null, "collectonly", PrivacyMode.CollectOnly)]
    [InlineData(null, "train-only", PrivacyMode.TrainOnly)]
    [InlineData(null, "both", PrivacyMode.Both)]
    [InlineData(null, "off", PrivacyMode.Both)]
    [InlineData(null, "0", PrivacyMode.Both)]
    // 自定义头优先于 authorization(忽略 authorization 里的 privacy)
    [InlineData("Bearer {\"privacy\":\"both\"}", "safe", PrivacyMode.Safe)]
    [InlineData("Bearer {\"privacy\":\"safe\"}", "both", PrivacyMode.Both)]
    // 头值非法 → 回退 authorization
    [InlineData("Bearer {\"privacy\":\"safe\"}", "garbage", PrivacyMode.Safe)]
    [InlineData("Bearer {\"privacy\":\"both\"}", "garbage", PrivacyMode.Both)]
    public void Parse_自定义头优先于authorization(string? auth, string? header, PrivacyMode privacy)
    {
        var options = RouteOptionsParser.Parse(auth, header);

        Assert.Equal(privacy, options.Privacy);
    }

    [Fact]
    public void Parse_自定义头不覆盖selectmode()
    {
        var options = RouteOptionsParser.Parse("Bearer {\"selectmode\":\"smart\"}", "safe");

        Assert.Equal(SelectMode.Smart, options.Mode);
        Assert.Equal(PrivacyMode.Safe, options.Privacy);
    }
}
+0 −24
Original line number Diff line number Diff line
using NoCost;

namespace NoCost.Tests;

public class SelectModeParserTests
{
    [Theory]
    [InlineData("Bearer {\"selectmode\":\"fast\"}", SelectMode.Fast)]
    [InlineData("Bearer {\"selectmode\":\"smart\"}", SelectMode.Smart)]
    [InlineData("Bearer {\"selectmode\":\"fast-smart\"}", SelectMode.FastSmart)]
    [InlineData("Bearer {\"selectmode\":\"smart-fast\"}", SelectMode.FastSmart)]
    [InlineData("Bearer {\"selectmode\":\"Fast\"}", SelectMode.Fast)]
    [InlineData("Bearer {\"selectmode\":\"unknown\"}", SelectMode.Fast)]
    [InlineData("Bearer {\"selectmode\":}", SelectMode.Fast)]
    [InlineData("Bearer arbitrary-key", SelectMode.Fast)]
    [InlineData("Bearer 123456", SelectMode.Fast)]
    [InlineData("Bearer ", SelectMode.Fast)]
    [InlineData("", SelectMode.Fast)]
    [InlineData(null, SelectMode.Fast)]
    public void Parse_返回预期模式(string? authorization, SelectMode expected)
    {
        Assert.Equal(expected, SelectModeParser.Parse(authorization));
    }
}
Loading