|
| 1 | +using System.Text.Json; |
| 2 | +using System.Text.Json.Serialization; |
| 3 | +using CodexSwitch.Models; |
| 4 | +using CodexSwitch.Proxy; |
| 5 | +using CodexSwitch.Services; |
| 6 | + |
| 7 | +namespace CodexSwitch.Tests; |
| 8 | + |
| 9 | +public sealed class BuiltInCatalogMigrationTests |
| 10 | +{ |
| 11 | + private static readonly JsonSerializerOptions JsonOptions = new() |
| 12 | + { |
| 13 | + WriteIndented = true, |
| 14 | + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, |
| 15 | + Converters = { new JsonStringEnumConverter() } |
| 16 | + }; |
| 17 | + |
| 18 | + [Fact] |
| 19 | + public void LoadPricing_UpgradesBuiltInCatalogToOfficialDefaults() |
| 20 | + { |
| 21 | + var root = CreateTempDirectory(); |
| 22 | + try |
| 23 | + { |
| 24 | + var paths = new AppPaths(root, Path.Combine(root, ".codex")); |
| 25 | + var store = new ConfigurationStore(paths); |
| 26 | + |
| 27 | + var legacy = new ModelPricingCatalog |
| 28 | + { |
| 29 | + SchemaVersion = "1.0", |
| 30 | + Currency = "USD", |
| 31 | + BillingUnitTokens = 1_000_000, |
| 32 | + FastMode = |
| 33 | + { |
| 34 | + DefaultMultiplier = 2m, |
| 35 | + ModelOverrides = |
| 36 | + { |
| 37 | + ["gpt-5.5"] = 2.5m |
| 38 | + } |
| 39 | + }, |
| 40 | + Models = |
| 41 | + { |
| 42 | + new ModelPricingRule |
| 43 | + { |
| 44 | + Id = "gpt-5.5", |
| 45 | + DisplayName = "GPT-5.5", |
| 46 | + IconSlug = "openai", |
| 47 | + Input = FlatTable(1.25m), |
| 48 | + CachedInput = FlatTable(0.125m), |
| 49 | + Output = FlatTable(10m) |
| 50 | + } |
| 51 | + } |
| 52 | + }; |
| 53 | + |
| 54 | + WriteJson(paths.PricingPath, legacy); |
| 55 | + |
| 56 | + var upgraded = store.LoadPricing(); |
| 57 | + |
| 58 | + Assert.Equal(BuiltInModelCatalog.PricingSchemaVersion, upgraded.SchemaVersion); |
| 59 | + Assert.Contains(upgraded.Models, rule => rule.Id == "gpt-5.4"); |
| 60 | + Assert.Contains(upgraded.Models, rule => rule.Id == "gpt-5.4-mini"); |
| 61 | + Assert.Contains(upgraded.Models, rule => rule.Id == "gpt-5.3-codex"); |
| 62 | + Assert.Contains(upgraded.Models, rule => rule.Id == "claude-opus-4-7"); |
| 63 | + Assert.Contains(upgraded.Models, rule => rule.Id == "claude-3-5-sonnet"); |
| 64 | + Assert.Contains(upgraded.Models, rule => rule.Id == "deepseek-v4-flash"); |
| 65 | + Assert.Contains(upgraded.Models, rule => rule.Id == "deepseek-v4-pro"); |
| 66 | + Assert.Contains(upgraded.Models, rule => rule.Id == "mimo-v2.5-pro"); |
| 67 | + Assert.Contains(upgraded.Models, rule => rule.Id == "mimo-v2.5"); |
| 68 | + |
| 69 | + var gpt55 = Assert.Single(upgraded.Models, rule => rule.Id == "gpt-5.5"); |
| 70 | + Assert.Equal(5m, gpt55.Input.Tiers[0].PricePerUnit); |
| 71 | + Assert.Equal(BuiltInModelCatalog.OpenAiLongContextThresholdTokens, gpt55.Input.Tiers[0].UpToTokens); |
| 72 | + Assert.True(upgraded.FastMode.ModelOverrides.ContainsKey("gpt-5.5*")); |
| 73 | + Assert.True(upgraded.FastMode.ModelOverrides.ContainsKey("gpt-5")); |
| 74 | + |
| 75 | + var deepSeekFlash = Assert.Single(upgraded.Models, rule => rule.Id == "deepseek-v4-flash"); |
| 76 | + Assert.Contains("deepseek-chat", deepSeekFlash.Aliases); |
| 77 | + Assert.Contains("deepseek-reasoner", deepSeekFlash.Aliases); |
| 78 | + |
| 79 | + var mimoPro = Assert.Single(upgraded.Models, rule => rule.Id == "mimo-v2.5-pro"); |
| 80 | + Assert.Contains("mimo-v2-pro", mimoPro.Aliases); |
| 81 | + Assert.Equal(BuiltInModelCatalog.XiaomiLongContextThresholdTokens, mimoPro.Input.Tiers[0].UpToTokens); |
| 82 | + } |
| 83 | + finally |
| 84 | + { |
| 85 | + Directory.Delete(root, recursive: true); |
| 86 | + } |
| 87 | + } |
| 88 | + |
| 89 | + [Fact] |
| 90 | + public void LoadConfig_ExpandsBuiltInProviderModelLists() |
| 91 | + { |
| 92 | + var root = CreateTempDirectory(); |
| 93 | + try |
| 94 | + { |
| 95 | + var paths = new AppPaths(root, Path.Combine(root, ".codex")); |
| 96 | + var store = new ConfigurationStore(paths); |
| 97 | + |
| 98 | + var legacy = new AppConfig |
| 99 | + { |
| 100 | + ActiveProviderId = "openai-official", |
| 101 | + Providers = |
| 102 | + { |
| 103 | + new ProviderConfig |
| 104 | + { |
| 105 | + Id = "openai-official", |
| 106 | + BuiltinId = ProviderTemplateCatalog.OpenAiOfficialBuiltinId, |
| 107 | + DisplayName = "OpenAI Official", |
| 108 | + BaseUrl = "https://api.openai.com/v1", |
| 109 | + Protocol = ProviderProtocol.OpenAiResponses, |
| 110 | + DefaultModel = "gpt-5.5", |
| 111 | + Models = |
| 112 | + { |
| 113 | + new ModelRouteConfig { Id = "gpt-5.5", Protocol = ProviderProtocol.OpenAiResponses } |
| 114 | + } |
| 115 | + }, |
| 116 | + new ProviderConfig |
| 117 | + { |
| 118 | + Id = "anthropic", |
| 119 | + BuiltinId = ProviderTemplateCatalog.AnthropicBuiltinId, |
| 120 | + DisplayName = "Anthropic Messages", |
| 121 | + BaseUrl = "https://api.anthropic.com/v1", |
| 122 | + Protocol = ProviderProtocol.AnthropicMessages, |
| 123 | + DefaultModel = "claude-sonnet-4-5", |
| 124 | + Models = |
| 125 | + { |
| 126 | + new ModelRouteConfig { Id = "claude-sonnet-4-5", Protocol = ProviderProtocol.AnthropicMessages } |
| 127 | + } |
| 128 | + } |
| 129 | + } |
| 130 | + }; |
| 131 | + |
| 132 | + WriteJson(paths.ConfigPath, legacy); |
| 133 | + |
| 134 | + var upgraded = store.LoadConfig(); |
| 135 | + var openAi = Assert.Single(upgraded.Providers, provider => provider.Id == "openai-official"); |
| 136 | + var anthropic = Assert.Single(upgraded.Providers, provider => provider.Id == "anthropic"); |
| 137 | + var deepSeek = Assert.Single(upgraded.Providers, provider => |
| 138 | + string.Equals(provider.BuiltinId, ProviderTemplateCatalog.DeepSeekBuiltinId, StringComparison.OrdinalIgnoreCase)); |
| 139 | + var xiaomi = Assert.Single(upgraded.Providers, provider => |
| 140 | + string.Equals(provider.BuiltinId, ProviderTemplateCatalog.XiaomiBuiltinId, StringComparison.OrdinalIgnoreCase)); |
| 141 | + |
| 142 | + Assert.Contains(openAi.Models, model => model.Id == "gpt-5.4"); |
| 143 | + Assert.Contains(openAi.Models, model => model.Id == "gpt-5.4-mini"); |
| 144 | + Assert.Contains(openAi.Models, model => model.Id == "gpt-5.3-codex"); |
| 145 | + Assert.Contains(anthropic.Models, model => model.Id == "claude-opus-4-7"); |
| 146 | + Assert.Contains(anthropic.Models, model => model.Id == "claude-3-5-sonnet"); |
| 147 | + Assert.Equal(ProviderProtocol.OpenAiChat, deepSeek.Protocol); |
| 148 | + Assert.Contains(deepSeek.Models, model => model.Id == "deepseek-v4-flash"); |
| 149 | + Assert.Contains(deepSeek.Models, model => model.Id == "deepseek-reasoner"); |
| 150 | + Assert.Equal(ProviderProtocol.OpenAiChat, xiaomi.Protocol); |
| 151 | + Assert.Contains(xiaomi.Models, model => model.Id == "mimo-v2.5-pro"); |
| 152 | + Assert.Contains(xiaomi.Models, model => model.Id == "mimo-v2-flash"); |
| 153 | + } |
| 154 | + finally |
| 155 | + { |
| 156 | + Directory.Delete(root, recursive: true); |
| 157 | + } |
| 158 | + } |
| 159 | + |
| 160 | + [Fact] |
| 161 | + public void ProviderRoutingResolver_RoutesByRequestedModelAcrossProviders() |
| 162 | + { |
| 163 | + var config = new AppConfig |
| 164 | + { |
| 165 | + ActiveProviderId = "openai-official", |
| 166 | + Providers = |
| 167 | + { |
| 168 | + ProviderTemplateCatalog.CreateProvider(ProviderTemplateCatalog.OpenAiOfficialBuiltinId, []), |
| 169 | + ProviderTemplateCatalog.CreateProvider(ProviderTemplateCatalog.AnthropicBuiltinId, ["openai-official"]), |
| 170 | + ProviderTemplateCatalog.CreateProvider(ProviderTemplateCatalog.DeepSeekBuiltinId, ["openai-official", "anthropic"]), |
| 171 | + ProviderTemplateCatalog.CreateProvider(ProviderTemplateCatalog.XiaomiBuiltinId, ["openai-official", "anthropic", "deepseek"]) |
| 172 | + } |
| 173 | + }; |
| 174 | + |
| 175 | + var selection = ProviderRoutingResolver.Resolve(config, "claude-sonnet-4-5"); |
| 176 | + |
| 177 | + Assert.NotNull(selection); |
| 178 | + Assert.Equal("anthropic", selection!.Provider.Id); |
| 179 | + Assert.Equal("claude-sonnet-4-5", selection.Model?.Id); |
| 180 | + |
| 181 | + var deepSeekSelection = ProviderRoutingResolver.Resolve(config, "deepseek-reasoner"); |
| 182 | + Assert.NotNull(deepSeekSelection); |
| 183 | + Assert.Equal("deepseek", deepSeekSelection!.Provider.Id); |
| 184 | + Assert.Equal("deepseek-reasoner", deepSeekSelection.Model?.Id); |
| 185 | + |
| 186 | + var xiaomiSelection = ProviderRoutingResolver.Resolve(config, "mimo-v2-pro"); |
| 187 | + Assert.NotNull(xiaomiSelection); |
| 188 | + Assert.Equal("xiaomi-mimo", xiaomiSelection!.Provider.Id); |
| 189 | + Assert.Equal("mimo-v2-pro", xiaomiSelection.Model?.Id); |
| 190 | + |
| 191 | + var listings = ProviderRoutingResolver.CollectModelListings(config); |
| 192 | + var gpt54 = Assert.Single(listings, item => item.Id == "gpt-5.4"); |
| 193 | + var deepSeekFlash = Assert.Single(listings, item => item.Id == "deepseek-v4-flash"); |
| 194 | + var mimoPro = Assert.Single(listings, item => item.Id == "mimo-v2.5-pro"); |
| 195 | + Assert.Contains("openai-official", gpt54.ProviderIds); |
| 196 | + Assert.Contains("deepseek", deepSeekFlash.ProviderIds); |
| 197 | + Assert.Contains("xiaomi-mimo", mimoPro.ProviderIds); |
| 198 | + } |
| 199 | + |
| 200 | + [Fact] |
| 201 | + public void IconCacheService_UsesOfficialXiaomiFallbackIconUrl() |
| 202 | + { |
| 203 | + var root = CreateTempDirectory(); |
| 204 | + try |
| 205 | + { |
| 206 | + var paths = new AppPaths(root, Path.Combine(root, ".codex")); |
| 207 | + using var httpClient = new HttpClient(); |
| 208 | + var icons = new IconCacheService(paths, httpClient); |
| 209 | + |
| 210 | + Assert.Equal( |
| 211 | + "https://platform.xiaomimimo.com/static/favicon.874c9507.png", |
| 212 | + icons.GetIconUrl("xiaomi")); |
| 213 | + Assert.Equal("xiaomi", IconCacheService.ResolveModelIconSlug("mimo-v2.5-pro")); |
| 214 | + } |
| 215 | + finally |
| 216 | + { |
| 217 | + Directory.Delete(root, recursive: true); |
| 218 | + } |
| 219 | + } |
| 220 | + |
| 221 | + private static TokenPriceTable FlatTable(decimal price) |
| 222 | + { |
| 223 | + var table = new TokenPriceTable(); |
| 224 | + table.Tiers.Add(new PricingTier { UpToTokens = null, PricePerUnit = price }); |
| 225 | + return table; |
| 226 | + } |
| 227 | + |
| 228 | + private static void WriteJson<T>(string path, T value) |
| 229 | + { |
| 230 | + Directory.CreateDirectory(Path.GetDirectoryName(path)!); |
| 231 | + File.WriteAllText(path, JsonSerializer.Serialize(value, JsonOptions)); |
| 232 | + } |
| 233 | + |
| 234 | + private static string CreateTempDirectory() |
| 235 | + { |
| 236 | + var path = Path.Combine(Path.GetTempPath(), "CodexSwitchTests", Guid.NewGuid().ToString("N")); |
| 237 | + Directory.CreateDirectory(path); |
| 238 | + return path; |
| 239 | + } |
| 240 | +} |
0 commit comments