Skip to content

Commit 6a0551a

Browse files
committed
feat: initial CodexSwitch app
0 parents  commit 6a0551a

163 files changed

Lines changed: 19021 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
pull_request:
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
verify:
14+
runs-on: windows-latest
15+
16+
steps:
17+
- name: Checkout
18+
uses: actions/checkout@v6
19+
20+
- name: Setup .NET
21+
uses: actions/setup-dotnet@v5
22+
with:
23+
dotnet-version: 10.0.203
24+
cache: true
25+
cache-dependency-path: |
26+
global.json
27+
Directory.Packages.props
28+
CodexSwitch/CodexSwitch.csproj
29+
CodexSwitch.Tests/CodexSwitch.Tests.csproj
30+
31+
- name: Validate changelog
32+
shell: pwsh
33+
run: ./build/Validate-Changelog.ps1
34+
35+
- name: Restore
36+
run: dotnet restore CodexSwitch.Tests/CodexSwitch.Tests.csproj
37+
38+
- name: Build
39+
run: dotnet build CodexSwitch.Tests/CodexSwitch.Tests.csproj -c Release --no-restore
40+
41+
- name: Test
42+
run: dotnet test CodexSwitch.Tests/CodexSwitch.Tests.csproj -c Release --no-build --no-restore --verbosity normal
43+
44+
publish:
45+
needs: verify
46+
strategy:
47+
fail-fast: false
48+
matrix:
49+
include:
50+
- runner: windows-latest
51+
rid: win-x64
52+
- runner: ubuntu-latest
53+
rid: linux-x64
54+
- runner: macos-13
55+
rid: osx-x64
56+
- runner: macos-14
57+
rid: osx-arm64
58+
59+
runs-on: ${{ matrix.runner }}
60+
61+
steps:
62+
- name: Checkout
63+
uses: actions/checkout@v6
64+
65+
- name: Setup .NET
66+
uses: actions/setup-dotnet@v5
67+
with:
68+
dotnet-version: 10.0.203
69+
cache: true
70+
cache-dependency-path: |
71+
global.json
72+
Directory.Packages.props
73+
CodexSwitch/CodexSwitch.csproj
74+
CodexSwitch.Tests/CodexSwitch.Tests.csproj
75+
76+
- name: Read version
77+
id: version
78+
shell: pwsh
79+
run: |
80+
[xml]$props = Get-Content -LiteralPath "Directory.Build.props"
81+
$version = $props.Project.PropertyGroup.Version
82+
"value=$version" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
83+
84+
- name: Publish ${{ matrix.rid }}
85+
run: >
86+
dotnet publish CodexSwitch/CodexSwitch.csproj
87+
-c Release
88+
-r ${{ matrix.rid }}
89+
--self-contained true
90+
-o artifacts/publish/${{ matrix.rid }}
91+
92+
- name: Package artifact
93+
id: package
94+
shell: pwsh
95+
run: |
96+
$version = "${{ steps.version.outputs.value }}"
97+
$artifactName = "CodexSwitch-v$version-${{ matrix.rid }}"
98+
$publishDirectory = Join-Path $PWD "artifacts/publish/${{ matrix.rid }}"
99+
$packageDirectory = Join-Path $PWD "artifacts/package"
100+
101+
New-Item -ItemType Directory -Force -Path $packageDirectory | Out-Null
102+
103+
$zipPath = Join-Path $packageDirectory "$artifactName.zip"
104+
if (Test-Path -LiteralPath $zipPath) {
105+
Remove-Item -LiteralPath $zipPath -Force
106+
}
107+
108+
Compress-Archive -Path (Join-Path $publishDirectory "*") -DestinationPath $zipPath
109+
110+
"name=$artifactName" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
111+
"path=$zipPath" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
112+
113+
- name: Upload artifact
114+
uses: actions/upload-artifact@v4
115+
with:
116+
name: ${{ steps.package.outputs.name }}
117+
path: ${{ steps.package.outputs.path }}
118+
if-no-files-found: error

‎.gitignore‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
bin/
2+
obj/
3+
/packages/
4+
riderModule.iml
5+
/_ReSharper.Caches/
6+
.idea/
7+
*.DotSettings.user
8+
TestResults/
9+
artifacts/

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [v1.0.0] - 2026-05-12
9+
10+
### Added
11+
12+
- Added repository-level SDK pinning with `global.json` for `.NET 10.0.203`.
13+
- Added centralized package and assembly version management for the Avalonia app and tests.
14+
- Added GitHub Actions CI validation plus multi-platform publish artifacts for `win-x64`, `linux-x64`, `osx-x64`, and `osx-arm64`.
15+
- Added GitHub Release update checking in the Avalonia About section for the `AIDotNet/CodexSwitch` repository.
16+
- Added release process documentation and changelog validation automation for future manual GitHub Releases.
17+
18+
### Fixed
19+
20+
- Fixed the current `CsSegmentedButton` compile error that blocked local builds and CI.
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
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

Comments
 (0)