From 5a233cd258090dfd59dba79d20d702fb5af89ae8 Mon Sep 17 00:00:00 2001 From: Forbes Fields Date: Thu, 20 Aug 2026 17:18:35 -0600 Subject: [PATCH 1/3] Checkpoint before removing duplicate menu bar item --- CoderSwitch.xcodeproj/project.pbxproj | 235 +--- CoderSwitch/App/AppContext.swift | 1 + CoderSwitch/App/CoderSwitchApp.swift | 341 +++++- CoderSwitch/Database/Database.swift | 98 -- CoderSwitch/Database/SecretBox.swift | 51 - CoderSwitch/Models/Account.swift | 222 ---- CoderSwitch/Models/OAuthAccount.swift | 72 -- CoderSwitch/Models/OAuthProvider.swift | 126 -- CoderSwitch/Models/OAuthToken.swift | 59 - CoderSwitch/Models/PKCEChallenge.swift | 23 - CoderSwitch/Models/Provider.swift | 70 -- CoderSwitch/Models/ProviderModel.swift | 15 - CoderSwitch/Models/ProviderModelFetcher.swift | 72 -- CoderSwitch/Models/ProxyRequestLog.swift | 60 - CoderSwitch/Models/QuotaCheck.swift | 263 ----- CoderSwitch/Models/TokenUsage.swift | 218 ---- CoderSwitch/Models/UsageLimit.swift | 118 -- CoderSwitch/Proxy/ModelRouter.swift | 146 --- CoderSwitch/Proxy/ProxyManager.swift | 144 --- CoderSwitch/Proxy/ProxyServer.swift | 542 --------- CoderSwitch/Proxy/ProxySettings.swift | 131 -- CoderSwitch/Proxy/ProxyState.swift | 52 - CoderSwitch/Proxy/QuotaPoller.swift | 616 ---------- CoderSwitch/Proxy/RequestLogRecorder.swift | 15 - CoderSwitch/Proxy/TokenUsageRecorder.swift | 15 - CoderSwitch/Proxy/UpstreamForwarder.swift | 94 -- CoderSwitch/Proxy/UsageTrackingStream.swift | 155 --- CoderSwitch/Stores/AccountStore.swift | 376 ------ .../Stores/ClaudeCodeConfigSwitcher.swift | 121 -- CoderSwitch/Stores/ConfigBackupStore.swift | 109 -- CoderSwitch/Stores/KeychainStore.swift | 70 -- CoderSwitch/Stores/OAuthCallbackHandler.swift | 14 - CoderSwitch/Stores/OAuthStore.swift | 1052 ----------------- CoderSwitch/Stores/RequestLogStore.swift | 145 --- CoderSwitch/Views/AboutView.swift | 1 + CoderSwitch/Views/AccountsTab.swift | 419 ++++--- CoderSwitch/Views/AddAccountView.swift | 53 +- CoderSwitch/Views/CircuitBreakerSheet.swift | 1 + CoderSwitch/Views/ConfigBackupTab.swift | 1 + CoderSwitch/Views/OAuthAccountsTab.swift | 1 + CoderSwitch/Views/OAuthFlowView.swift | 1 + CoderSwitch/Views/ProxyTab.swift | 10 +- CoderSwitch/Views/SettingsWindow.swift | 1 + CoderSwitch/Views/StatusPopover.swift | 35 +- CoderSwitch/Views/UsageTab.swift | 221 ++-- .../ClaudeCodeConfigSwitcherTests.swift | 115 -- CoderSwitchTests/ModelRouterTests.swift | 90 -- CoderSwitchTests/QuotaCheckTests.swift | 310 ----- CoderSwitchTests/TokenUsageParserTests.swift | 62 - README.md | 47 +- project.yml | 22 +- 51 files changed, 854 insertions(+), 6377 deletions(-) delete mode 100644 CoderSwitch/Database/Database.swift delete mode 100644 CoderSwitch/Database/SecretBox.swift delete mode 100644 CoderSwitch/Models/Account.swift delete mode 100644 CoderSwitch/Models/OAuthAccount.swift delete mode 100644 CoderSwitch/Models/OAuthProvider.swift delete mode 100644 CoderSwitch/Models/OAuthToken.swift delete mode 100644 CoderSwitch/Models/PKCEChallenge.swift delete mode 100644 CoderSwitch/Models/Provider.swift delete mode 100644 CoderSwitch/Models/ProviderModel.swift delete mode 100644 CoderSwitch/Models/ProviderModelFetcher.swift delete mode 100644 CoderSwitch/Models/ProxyRequestLog.swift delete mode 100644 CoderSwitch/Models/QuotaCheck.swift delete mode 100644 CoderSwitch/Models/TokenUsage.swift delete mode 100644 CoderSwitch/Models/UsageLimit.swift delete mode 100644 CoderSwitch/Proxy/ModelRouter.swift delete mode 100644 CoderSwitch/Proxy/ProxyManager.swift delete mode 100644 CoderSwitch/Proxy/ProxyServer.swift delete mode 100644 CoderSwitch/Proxy/ProxySettings.swift delete mode 100644 CoderSwitch/Proxy/ProxyState.swift delete mode 100644 CoderSwitch/Proxy/QuotaPoller.swift delete mode 100644 CoderSwitch/Proxy/RequestLogRecorder.swift delete mode 100644 CoderSwitch/Proxy/TokenUsageRecorder.swift delete mode 100644 CoderSwitch/Proxy/UpstreamForwarder.swift delete mode 100644 CoderSwitch/Proxy/UsageTrackingStream.swift delete mode 100644 CoderSwitch/Stores/AccountStore.swift delete mode 100644 CoderSwitch/Stores/ClaudeCodeConfigSwitcher.swift delete mode 100644 CoderSwitch/Stores/ConfigBackupStore.swift delete mode 100644 CoderSwitch/Stores/KeychainStore.swift delete mode 100644 CoderSwitch/Stores/OAuthCallbackHandler.swift delete mode 100644 CoderSwitch/Stores/OAuthStore.swift delete mode 100644 CoderSwitch/Stores/RequestLogStore.swift delete mode 100644 CoderSwitchTests/ClaudeCodeConfigSwitcherTests.swift delete mode 100644 CoderSwitchTests/ModelRouterTests.swift delete mode 100644 CoderSwitchTests/QuotaCheckTests.swift delete mode 100644 CoderSwitchTests/TokenUsageParserTests.swift diff --git a/CoderSwitch.xcodeproj/project.pbxproj b/CoderSwitch.xcodeproj/project.pbxproj index dabd7e0..3cbbbc0 100644 --- a/CoderSwitch.xcodeproj/project.pbxproj +++ b/CoderSwitch.xcodeproj/project.pbxproj @@ -7,57 +7,22 @@ objects = { /* Begin PBXBuildFile section */ - 001FB4ECA34F2E41658B919F /* ClaudeCodeConfigSwitcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58322D80D8C9082A994BF5BD /* ClaudeCodeConfigSwitcherTests.swift */; }; + 0A1A24DE4F63351666D9BFB9 /* CoderSwitchCore in Frameworks */ = {isa = PBXBuildFile; productRef = E9F08192807B27FBBC655A2C /* CoderSwitchCore */; }; 0CB3CBE18175C59C35784BEE /* CircuitBreakerSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81EEFDDEAA9E3836B661F960 /* CircuitBreakerSheet.swift */; }; - 0EAEBE3B17BC9D349188F6BA /* UsageLimit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8ED3FA9D122BF9091D99E187 /* UsageLimit.swift */; }; 1269F8919996CBF77092F65F /* OAuthAccountsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D0159E8324F52BC3DA43A96 /* OAuthAccountsTab.swift */; }; 1F3AC856E3F556F5EB4ED7CA /* SettingsWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077657DD1B7A7572435EA49B /* SettingsWindow.swift */; }; - 246FE8979136329EC55283B7 /* Account.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD9AEFA7D0420620E8183B77 /* Account.swift */; }; - 32DACA79FED0DEDA2A3FA27A /* ProxyRequestLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B20D5A3A47406E28C626078 /* ProxyRequestLog.swift */; }; - 357F58D59F4B7D58FCFA8DF9 /* RequestLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 807C8FEFE4CC26223337DCC5 /* RequestLogStore.swift */; }; 391465E29C96654587A6BA38 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9F4AEA0DF138EB072B739E5C /* Assets.xcassets */; }; - 55EE4B7C47CCB9D7AF3CF415 /* OAuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 104188B42EBDB41B97BB62A1 /* OAuthStore.swift */; }; - 5B90F8D26179AB4B35B5730A /* SecretBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01F5C0947D9160482C563102 /* SecretBox.swift */; }; - 6357B28981C6BC13B82C7181 /* TokenUsageRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABE58FF9392622BE499AE25F /* TokenUsageRecorder.swift */; }; 667E3E3F1DE6E16BBE3E9E81 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609E7E0F96063655E2474AB6 /* AboutView.swift */; }; - 68DEB6E196D73953EE010EC6 /* ProxyState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5544B876FC74485BDFD8FE3 /* ProxyState.swift */; }; - 691650ABAB8A1E0D537151A4 /* OAuthCallbackHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4118F3E258AFF12FCF31A760 /* OAuthCallbackHandler.swift */; }; - 6D0DEC0C9E30A8D921EAE091 /* ModelRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83CA7AF54AEFAB4C89509FB5 /* ModelRouterTests.swift */; }; 73E066C3AA5A60AB99B0E1F6 /* OAuthFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CD9D6312AC2958382BCF570 /* OAuthFlowView.swift */; }; - 7656ACB09EC2566583A37309 /* ProxyServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 008B477F0B409325A2755D8F /* ProxyServer.swift */; }; - 7ABCB1891B24FF145A3E83FC /* ModelRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D261BFF65E7AC18404CAB3E0 /* ModelRouter.swift */; }; - 856F749BDA9F2DA4F4B495D9 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2096B5707A99C277FBB2C235 /* Database.swift */; }; - 8F5CFF5979F78862CA4A4738 /* ConfigBackupStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD0FE4F8E9D8D8D4773C0DE1 /* ConfigBackupStore.swift */; }; - 9210D5039238CC9ED5E22FDD /* QuotaCheck.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFD0DD4DB2F8D4E7C2358FDD /* QuotaCheck.swift */; }; 964EE21AB5A195100E8158FE /* AccountsTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = F59CEF2EC0968CCC78025016 /* AccountsTab.swift */; }; - 98427D1DA22754C5107AD761 /* ProviderModelFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA24727C539478DFA42CDCA0 /* ProviderModelFetcher.swift */; }; - 9B0C4D25C8F9CB29DB8624E0 /* QuotaPoller.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB71DE241F9931D07468DA5C /* QuotaPoller.swift */; }; - 9FCDECD236E6E31DD76047A6 /* UpstreamForwarder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83961DA85B0617EC4FEDD1F2 /* UpstreamForwarder.swift */; }; - A494D1727F2B2A53EE3F980D /* QuotaCheckTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08B3F2ACE33B81CE192486B1 /* QuotaCheckTests.swift */; }; - AF74A1DE4461F2DA20399596 /* ProviderModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 548062CAE7C4E77753A11644 /* ProviderModel.swift */; }; - B74E7398433C9F0A0B2BCC52 /* UsageTrackingStream.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D3AE3ADA85EC86835E1FB82 /* UsageTrackingStream.swift */; }; - B81AE4B03E4AE0F8871A48DF /* TokenUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5153CFFC5E9C351A5AF113 /* TokenUsage.swift */; }; - C259150CE3BAD187F31337DA /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 11B2F1511AB930FB7E77E2E1 /* GRDB */; }; - C3EC0947AA49D3DAB066A7A7 /* PKCEChallenge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F5758ABC9EC1DD1875F4C6E /* PKCEChallenge.swift */; }; CCF3365E3961E42925D70FD8 /* ProxyTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A69F65CBBB5CF90360D68B5 /* ProxyTab.swift */; }; CEE8DEF6256546F461ED4B41 /* AddAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB840FAD3941FA139CC9E702 /* AddAccountView.swift */; }; - CF8A4A5C99ADBECAD384FE65 /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE26B5CB50F01B14C047F957 /* AccountStore.swift */; }; D287F853C9ACE52751FF2691 /* CoderSwitchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A73CA1A6C2BD0D6FA8A4264 /* CoderSwitchApp.swift */; }; D31E586EDB5B335872B01613 /* StatusPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62340C112C4016EE69899E79 /* StatusPopover.swift */; }; - DD3CB4A4296ED6B52EB23CB6 /* OAuthToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99946A26D70A6F1B1BE237E7 /* OAuthToken.swift */; }; E2748B0DA0E6CFB150910D4A /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B6C4F1FDB85D1AB6A64D29 /* AppContext.swift */; }; E434167283DD555683713E7D /* UsageTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9264D404AC2D98E9FA6D966E /* UsageTab.swift */; }; - E4423422F400C455129C20F3 /* ClaudeCodeConfigSwitcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = F852D82F1A8A798F0E9C285E /* ClaudeCodeConfigSwitcher.swift */; }; - E5FB9E9A2767938B92B3BE1A /* RequestLogRecorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5F154BFADA0E4F8A2289123 /* RequestLogRecorder.swift */; }; E6BF0D8A18AFE13F961785D6 /* ConfigBackupTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05E76DECD9AA749277C78C92 /* ConfigBackupTab.swift */; }; - EFB531D260182581DDDC9ACA /* ProxySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF98CA6635C598EE6E4A8765 /* ProxySettings.swift */; }; - F2692AAB4D49DC193B10D2FD /* ProxyManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB0B734EF9937BC6B3544038 /* ProxyManager.swift */; }; - F5A22B9E06EB249EAD9641EB /* OAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9A2CB8B87C53C80DAD8096 /* OAuthProvider.swift */; }; - F77E2955AABCD67D798447DB /* TokenUsageParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D6014C3CF0C3AFB6ABC3EBC /* TokenUsageParserTests.swift */; }; - F97721CA82B69FADAAB527EC /* Provider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 552007D1DE6840D60E609738 /* Provider.swift */; }; - FB31A533333A79EC651CE8E2 /* OAuthAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CD6F122CC8E46D3213FB893 /* OAuthAccount.swift */; }; - FC28A6387C5E9E6432DA0DBD /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 6DD4D2381B5EF289B54CE79E /* Hummingbird */; }; - FF8867DE2C25E6A83F71DFCE /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E43B1E2E44D34F440D1B2E6 /* KeychainStore.swift */; }; + FC28A6387C5E9E6432DA0DBD /* CoderSwitchCore in Frameworks */ = {isa = PBXBuildFile; productRef = D5EF4664EB583758F50084CF /* CoderSwitchCore */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -71,59 +36,23 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 008B477F0B409325A2755D8F /* ProxyServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyServer.swift; sourceTree = ""; }; - 01F5C0947D9160482C563102 /* SecretBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretBox.swift; sourceTree = ""; }; 05E76DECD9AA749277C78C92 /* ConfigBackupTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigBackupTab.swift; sourceTree = ""; }; 077657DD1B7A7572435EA49B /* SettingsWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindow.swift; sourceTree = ""; }; - 08B3F2ACE33B81CE192486B1 /* QuotaCheckTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaCheckTests.swift; sourceTree = ""; }; 0A4A609601CBE498535B10BA /* CoderSwitchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CoderSwitchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 104188B42EBDB41B97BB62A1 /* OAuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthStore.swift; sourceTree = ""; }; 1A69F65CBBB5CF90360D68B5 /* ProxyTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyTab.swift; sourceTree = ""; }; - 2096B5707A99C277FBB2C235 /* Database.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Database.swift; sourceTree = ""; }; 28B6C4F1FDB85D1AB6A64D29 /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = ""; }; - 3E43B1E2E44D34F440D1B2E6 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; }; - 3F5758ABC9EC1DD1875F4C6E /* PKCEChallenge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCEChallenge.swift; sourceTree = ""; }; - 4118F3E258AFF12FCF31A760 /* OAuthCallbackHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthCallbackHandler.swift; sourceTree = ""; }; - 4D3AE3ADA85EC86835E1FB82 /* UsageTrackingStream.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageTrackingStream.swift; sourceTree = ""; }; - 548062CAE7C4E77753A11644 /* ProviderModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModel.swift; sourceTree = ""; }; - 552007D1DE6840D60E609738 /* Provider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Provider.swift; sourceTree = ""; }; - 58322D80D8C9082A994BF5BD /* ClaudeCodeConfigSwitcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeConfigSwitcherTests.swift; sourceTree = ""; }; 609E7E0F96063655E2474AB6 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; 62340C112C4016EE69899E79 /* StatusPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusPopover.swift; sourceTree = ""; }; - 6B20D5A3A47406E28C626078 /* ProxyRequestLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyRequestLog.swift; sourceTree = ""; }; - 6CD6F122CC8E46D3213FB893 /* OAuthAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAccount.swift; sourceTree = ""; }; - 6E7A61C9846E21FE89780CA9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 7C9A2CB8B87C53C80DAD8096 /* OAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthProvider.swift; sourceTree = ""; }; 7CD9D6312AC2958382BCF570 /* OAuthFlowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthFlowView.swift; sourceTree = ""; }; - 807C8FEFE4CC26223337DCC5 /* RequestLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestLogStore.swift; sourceTree = ""; }; 81EEFDDEAA9E3836B661F960 /* CircuitBreakerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircuitBreakerSheet.swift; sourceTree = ""; }; - 83961DA85B0617EC4FEDD1F2 /* UpstreamForwarder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpstreamForwarder.swift; sourceTree = ""; }; - 83CA7AF54AEFAB4C89509FB5 /* ModelRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelRouterTests.swift; sourceTree = ""; }; 8D0159E8324F52BC3DA43A96 /* OAuthAccountsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthAccountsTab.swift; sourceTree = ""; }; - 8ED3FA9D122BF9091D99E187 /* UsageLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageLimit.swift; sourceTree = ""; }; + 8ECEB49362F420191334DB65 /* CoderSwitchCore */ = {isa = PBXFileReference; lastKnownFileType = folder; name = CoderSwitchCore; path = "../coderswitch-studio/Packages/CoderSwitchCore"; sourceTree = SOURCE_ROOT; }; 9264D404AC2D98E9FA6D966E /* UsageTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsageTab.swift; sourceTree = ""; }; - 99946A26D70A6F1B1BE237E7 /* OAuthToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OAuthToken.swift; sourceTree = ""; }; 9A73CA1A6C2BD0D6FA8A4264 /* CoderSwitchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoderSwitchApp.swift; sourceTree = ""; }; - 9D6014C3CF0C3AFB6ABC3EBC /* TokenUsageParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenUsageParserTests.swift; sourceTree = ""; }; 9F4AEA0DF138EB072B739E5C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - ABE58FF9392622BE499AE25F /* TokenUsageRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenUsageRecorder.swift; sourceTree = ""; }; - AFD0DD4DB2F8D4E7C2358FDD /* QuotaCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaCheck.swift; sourceTree = ""; }; BE37B8CEB84B2D78AD85DE4D /* CoderSwitch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CoderSwitch.app; sourceTree = BUILT_PRODUCTS_DIR; }; - C48E475AB3ADC42C94DAC1B4 /* CoderSwitch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CoderSwitch.entitlements; sourceTree = ""; }; - C5F154BFADA0E4F8A2289123 /* RequestLogRecorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestLogRecorder.swift; sourceTree = ""; }; - CA24727C539478DFA42CDCA0 /* ProviderModelFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderModelFetcher.swift; sourceTree = ""; }; - CB0B734EF9937BC6B3544038 /* ProxyManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyManager.swift; sourceTree = ""; }; - CB71DE241F9931D07468DA5C /* QuotaPoller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotaPoller.swift; sourceTree = ""; }; - CD9AEFA7D0420620E8183B77 /* Account.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Account.swift; sourceTree = ""; }; - CF98CA6635C598EE6E4A8765 /* ProxySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxySettings.swift; sourceTree = ""; }; - D261BFF65E7AC18404CAB3E0 /* ModelRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelRouter.swift; sourceTree = ""; }; - D5544B876FC74485BDFD8FE3 /* ProxyState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProxyState.swift; sourceTree = ""; }; - DC5153CFFC5E9C351A5AF113 /* TokenUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenUsage.swift; sourceTree = ""; }; - DE26B5CB50F01B14C047F957 /* AccountStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStore.swift; sourceTree = ""; }; EB840FAD3941FA139CC9E702 /* AddAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddAccountView.swift; sourceTree = ""; }; F59CEF2EC0968CCC78025016 /* AccountsTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountsTab.swift; sourceTree = ""; }; - F852D82F1A8A798F0E9C285E /* ClaudeCodeConfigSwitcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeCodeConfigSwitcher.swift; sourceTree = ""; }; - FD0FE4F8E9D8D8D4773C0DE1 /* ConfigBackupStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigBackupStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -131,8 +60,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - FC28A6387C5E9E6432DA0DBD /* Hummingbird in Frameworks */, - C259150CE3BAD187F31337DA /* GRDB in Frameworks */, + FC28A6387C5E9E6432DA0DBD /* CoderSwitchCore in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 9854394B882D96A71183942D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0A1A24DE4F63351666D9BFB9 /* CoderSwitchCore in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -142,14 +78,8 @@ 04F3A484ABDC997974F7466F /* CoderSwitch */ = { isa = PBXGroup; children = ( - C48E475AB3ADC42C94DAC1B4 /* CoderSwitch.entitlements */, - 6E7A61C9846E21FE89780CA9 /* Info.plist */, 1FD8A0EC7F5A3F81FB386BFB /* App */, - A1F61E49EE6FEF8B19B562BA /* Database */, - 975847A001D4C587C0830002 /* Models */, - 7EFB39CA86435DECE91665A2 /* Proxy */, A5DDE3E80AEC771683CBEFB2 /* Resources */, - 6C13C538DB45D41625FC6D94 /* Stores */, FC5E846CC5FBFFF61AFED111 /* Views */, ); path = CoderSwitch; @@ -164,63 +94,12 @@ path = App; sourceTree = ""; }; - 6C13C538DB45D41625FC6D94 /* Stores */ = { - isa = PBXGroup; - children = ( - DE26B5CB50F01B14C047F957 /* AccountStore.swift */, - F852D82F1A8A798F0E9C285E /* ClaudeCodeConfigSwitcher.swift */, - FD0FE4F8E9D8D8D4773C0DE1 /* ConfigBackupStore.swift */, - 3E43B1E2E44D34F440D1B2E6 /* KeychainStore.swift */, - 4118F3E258AFF12FCF31A760 /* OAuthCallbackHandler.swift */, - 104188B42EBDB41B97BB62A1 /* OAuthStore.swift */, - 807C8FEFE4CC26223337DCC5 /* RequestLogStore.swift */, - ); - path = Stores; - sourceTree = ""; - }; - 7EFB39CA86435DECE91665A2 /* Proxy */ = { - isa = PBXGroup; - children = ( - D261BFF65E7AC18404CAB3E0 /* ModelRouter.swift */, - CB0B734EF9937BC6B3544038 /* ProxyManager.swift */, - 008B477F0B409325A2755D8F /* ProxyServer.swift */, - CF98CA6635C598EE6E4A8765 /* ProxySettings.swift */, - D5544B876FC74485BDFD8FE3 /* ProxyState.swift */, - CB71DE241F9931D07468DA5C /* QuotaPoller.swift */, - C5F154BFADA0E4F8A2289123 /* RequestLogRecorder.swift */, - ABE58FF9392622BE499AE25F /* TokenUsageRecorder.swift */, - 83961DA85B0617EC4FEDD1F2 /* UpstreamForwarder.swift */, - 4D3AE3ADA85EC86835E1FB82 /* UsageTrackingStream.swift */, - ); - path = Proxy; - sourceTree = ""; - }; - 975847A001D4C587C0830002 /* Models */ = { - isa = PBXGroup; - children = ( - CD9AEFA7D0420620E8183B77 /* Account.swift */, - 6CD6F122CC8E46D3213FB893 /* OAuthAccount.swift */, - 7C9A2CB8B87C53C80DAD8096 /* OAuthProvider.swift */, - 99946A26D70A6F1B1BE237E7 /* OAuthToken.swift */, - 3F5758ABC9EC1DD1875F4C6E /* PKCEChallenge.swift */, - 552007D1DE6840D60E609738 /* Provider.swift */, - 548062CAE7C4E77753A11644 /* ProviderModel.swift */, - CA24727C539478DFA42CDCA0 /* ProviderModelFetcher.swift */, - 6B20D5A3A47406E28C626078 /* ProxyRequestLog.swift */, - AFD0DD4DB2F8D4E7C2358FDD /* QuotaCheck.swift */, - DC5153CFFC5E9C351A5AF113 /* TokenUsage.swift */, - 8ED3FA9D122BF9091D99E187 /* UsageLimit.swift */, - ); - path = Models; - sourceTree = ""; - }; - A1F61E49EE6FEF8B19B562BA /* Database */ = { + A02E24105105752D05F5A2EA /* Packages */ = { isa = PBXGroup; children = ( - 2096B5707A99C277FBB2C235 /* Database.swift */, - 01F5C0947D9160482C563102 /* SecretBox.swift */, + 8ECEB49362F420191334DB65 /* CoderSwitchCore */, ); - path = Database; + name = Packages; sourceTree = ""; }; A5DDE3E80AEC771683CBEFB2 /* Resources */ = { @@ -234,10 +113,6 @@ A8498A6608DCAFE471D5FCF7 /* CoderSwitchTests */ = { isa = PBXGroup; children = ( - 58322D80D8C9082A994BF5BD /* ClaudeCodeConfigSwitcherTests.swift */, - 83CA7AF54AEFAB4C89509FB5 /* ModelRouterTests.swift */, - 08B3F2ACE33B81CE192486B1 /* QuotaCheckTests.swift */, - 9D6014C3CF0C3AFB6ABC3EBC /* TokenUsageParserTests.swift */, ); path = CoderSwitchTests; sourceTree = ""; @@ -247,6 +122,7 @@ children = ( 04F3A484ABDC997974F7466F /* CoderSwitch */, A8498A6608DCAFE471D5FCF7 /* CoderSwitchTests */, + A02E24105105752D05F5A2EA /* Packages */, E79CDDCA2E87A9CA3838F98D /* Products */, ); sourceTree = ""; @@ -286,6 +162,7 @@ buildConfigurationList = 8496620BB0A193C9BBA4BB74 /* Build configuration list for PBXNativeTarget "CoderSwitchTests" */; buildPhases = ( 28F3842FAACEE66E5BAE3C49 /* Sources */, + 9854394B882D96A71183942D /* Frameworks */, ); buildRules = ( ); @@ -294,6 +171,7 @@ ); name = CoderSwitchTests; packageProductDependencies = ( + E9F08192807B27FBBC655A2C /* CoderSwitchCore */, ); productName = CoderSwitchTests; productReference = 0A4A609601CBE498535B10BA /* CoderSwitchTests.xctest */; @@ -313,8 +191,7 @@ ); name = CoderSwitch; packageProductDependencies = ( - 6DD4D2381B5EF289B54CE79E /* Hummingbird */, - 11B2F1511AB930FB7E77E2E1 /* GRDB */, + D5EF4664EB583758F50084CF /* CoderSwitchCore */, ); productName = CoderSwitch; productReference = BE37B8CEB84B2D78AD85DE4D /* CoderSwitch.app */; @@ -349,8 +226,7 @@ mainGroup = D5204D67A72BCE3450983FA4; minimizedProjectReferenceProxies = 1; packageReferences = ( - A829CACB0D7B50191FA4F97F /* XCRemoteSwiftPackageReference "GRDB.swift" */, - 871DFBA452C66C62C62A9C6A /* XCRemoteSwiftPackageReference "hummingbird" */, + DC59B4D765BFF384EB6E1CAA /* XCLocalSwiftPackageReference "../coderswitch-studio/Packages/CoderSwitchCore" */, ); preferredProjectObjectVersion = 77; productRefGroup = E79CDDCA2E87A9CA3838F98D /* Products */; @@ -379,10 +255,6 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 001FB4ECA34F2E41658B919F /* ClaudeCodeConfigSwitcherTests.swift in Sources */, - 6D0DEC0C9E30A8D921EAE091 /* ModelRouterTests.swift in Sources */, - A494D1727F2B2A53EE3F980D /* QuotaCheckTests.swift in Sources */, - F77E2955AABCD67D798447DB /* TokenUsageParserTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -391,49 +263,18 @@ buildActionMask = 2147483647; files = ( 667E3E3F1DE6E16BBE3E9E81 /* AboutView.swift in Sources */, - 246FE8979136329EC55283B7 /* Account.swift in Sources */, - CF8A4A5C99ADBECAD384FE65 /* AccountStore.swift in Sources */, 964EE21AB5A195100E8158FE /* AccountsTab.swift in Sources */, CEE8DEF6256546F461ED4B41 /* AddAccountView.swift in Sources */, E2748B0DA0E6CFB150910D4A /* AppContext.swift in Sources */, 0CB3CBE18175C59C35784BEE /* CircuitBreakerSheet.swift in Sources */, - E4423422F400C455129C20F3 /* ClaudeCodeConfigSwitcher.swift in Sources */, D287F853C9ACE52751FF2691 /* CoderSwitchApp.swift in Sources */, - 8F5CFF5979F78862CA4A4738 /* ConfigBackupStore.swift in Sources */, E6BF0D8A18AFE13F961785D6 /* ConfigBackupTab.swift in Sources */, - 856F749BDA9F2DA4F4B495D9 /* Database.swift in Sources */, - FF8867DE2C25E6A83F71DFCE /* KeychainStore.swift in Sources */, - 7ABCB1891B24FF145A3E83FC /* ModelRouter.swift in Sources */, - FB31A533333A79EC651CE8E2 /* OAuthAccount.swift in Sources */, 1269F8919996CBF77092F65F /* OAuthAccountsTab.swift in Sources */, - 691650ABAB8A1E0D537151A4 /* OAuthCallbackHandler.swift in Sources */, 73E066C3AA5A60AB99B0E1F6 /* OAuthFlowView.swift in Sources */, - F5A22B9E06EB249EAD9641EB /* OAuthProvider.swift in Sources */, - 55EE4B7C47CCB9D7AF3CF415 /* OAuthStore.swift in Sources */, - DD3CB4A4296ED6B52EB23CB6 /* OAuthToken.swift in Sources */, - C3EC0947AA49D3DAB066A7A7 /* PKCEChallenge.swift in Sources */, - F97721CA82B69FADAAB527EC /* Provider.swift in Sources */, - AF74A1DE4461F2DA20399596 /* ProviderModel.swift in Sources */, - 98427D1DA22754C5107AD761 /* ProviderModelFetcher.swift in Sources */, - F2692AAB4D49DC193B10D2FD /* ProxyManager.swift in Sources */, - 32DACA79FED0DEDA2A3FA27A /* ProxyRequestLog.swift in Sources */, - 7656ACB09EC2566583A37309 /* ProxyServer.swift in Sources */, - EFB531D260182581DDDC9ACA /* ProxySettings.swift in Sources */, - 68DEB6E196D73953EE010EC6 /* ProxyState.swift in Sources */, CCF3365E3961E42925D70FD8 /* ProxyTab.swift in Sources */, - 9210D5039238CC9ED5E22FDD /* QuotaCheck.swift in Sources */, - 9B0C4D25C8F9CB29DB8624E0 /* QuotaPoller.swift in Sources */, - E5FB9E9A2767938B92B3BE1A /* RequestLogRecorder.swift in Sources */, - 357F58D59F4B7D58FCFA8DF9 /* RequestLogStore.swift in Sources */, - 5B90F8D26179AB4B35B5730A /* SecretBox.swift in Sources */, 1F3AC856E3F556F5EB4ED7CA /* SettingsWindow.swift in Sources */, D31E586EDB5B335872B01613 /* StatusPopover.swift in Sources */, - B81AE4B03E4AE0F8871A48DF /* TokenUsage.swift in Sources */, - 6357B28981C6BC13B82C7181 /* TokenUsageRecorder.swift in Sources */, - 9FCDECD236E6E31DD76047A6 /* UpstreamForwarder.swift in Sources */, - 0EAEBE3B17BC9D349188F6BA /* UsageLimit.swift in Sources */, E434167283DD555683713E7D /* UsageTab.swift in Sources */, - B74E7398433C9F0A0B2BCC52 /* UsageTrackingStream.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -538,7 +379,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = dev.forbes.CoderSwitch; + PRODUCT_BUNDLE_IDENTIFIER = dev.forbes.CoderSwitchMenuBar; SDKROOT = macosx; }; name = Debug; @@ -556,7 +397,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = dev.forbes.CoderSwitch; + PRODUCT_BUNDLE_IDENTIFIER = dev.forbes.CoderSwitchMenuBar; SDKROOT = macosx; }; name = Release; @@ -677,35 +518,21 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 871DFBA452C66C62C62A9C6A /* XCRemoteSwiftPackageReference "hummingbird" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "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/hummingbird-project/hummingbird"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.0.0; - }; - }; - A829CACB0D7B50191FA4F97F /* XCRemoteSwiftPackageReference "GRDB.swift" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "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/groue/GRDB.swift"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 7.0.0; - }; +/* Begin XCLocalSwiftPackageReference section */ + DC59B4D765BFF384EB6E1CAA /* XCLocalSwiftPackageReference "../coderswitch-studio/Packages/CoderSwitchCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../coderswitch-studio/Packages/CoderSwitchCore"; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 11B2F1511AB930FB7E77E2E1 /* GRDB */ = { + D5EF4664EB583758F50084CF /* CoderSwitchCore */ = { isa = XCSwiftPackageProductDependency; - package = A829CACB0D7B50191FA4F97F /* XCRemoteSwiftPackageReference "GRDB.swift" */; - productName = GRDB; + productName = CoderSwitchCore; }; - 6DD4D2381B5EF289B54CE79E /* Hummingbird */ = { + E9F08192807B27FBBC655A2C /* CoderSwitchCore */ = { isa = XCSwiftPackageProductDependency; - package = 871DFBA452C66C62C62A9C6A /* XCRemoteSwiftPackageReference "hummingbird" */; - productName = Hummingbird; + productName = CoderSwitchCore; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/CoderSwitch/App/AppContext.swift b/CoderSwitch/App/AppContext.swift index 6fd8b78..04f61cc 100644 --- a/CoderSwitch/App/AppContext.swift +++ b/CoderSwitch/App/AppContext.swift @@ -1,4 +1,5 @@ import Foundation +import CoderSwitchCore import Observation /// Top-level container created exactly once at app launch. Owns the diff --git a/CoderSwitch/App/CoderSwitchApp.swift b/CoderSwitch/App/CoderSwitchApp.swift index 95b5f26..f417bae 100644 --- a/CoderSwitch/App/CoderSwitchApp.swift +++ b/CoderSwitch/App/CoderSwitchApp.swift @@ -1,31 +1,314 @@ import SwiftUI +import CoderSwitchCore import AppKit @main -struct CoderSwitchApp: App { - @State private var context = AppContext() - private let oauthStore = OAuthStore.shared +enum CoderSwitchMain { + @MainActor private static var delegate: AppDelegate? - var body: some Scene { - MenuBarExtra("CoderSwitch", image: "CoderSwitchMenuBarIcon") { - StatusPopover() + @MainActor + static func main() { + let app = NSApplication.shared + let delegate = AppDelegate() + self.delegate = delegate + app.delegate = delegate + app.run() + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private let context: AppContext + private let oauthStore: OAuthStore + private var keepAliveActivity: NSObjectProtocol? + + override init() { + let oauthStore = OAuthStore.shared + self.oauthStore = oauthStore + context = AppContext(oauthStore: oauthStore) + super.init() + AppShell.shared.configure(context: context, oauthStore: oauthStore) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + ProcessInfo.processInfo.disableAutomaticTermination("CoderSwitch menu bar utility") + ProcessInfo.processInfo.disableSuddenTermination() + keepAliveActivity = ProcessInfo.processInfo.beginActivity( + options: [.automaticTerminationDisabled, .suddenTerminationDisabled], + reason: "CoderSwitch menu bar utility" + ) + NativeStatusItemController.shared.install() + MenuBarPlaceholderController.shared.install() + } + + func applicationWillTerminate(_ notification: Notification) { + if let keepAliveActivity { + ProcessInfo.processInfo.endActivity(keepAliveActivity) + self.keepAliveActivity = nil + } + ProcessInfo.processInfo.enableAutomaticTermination("CoderSwitch menu bar utility") + ProcessInfo.processInfo.enableSuddenTermination() + } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + if AppTerminationController.shared.shouldAllowTermination { + return .terminateNow + } + #if DEBUG + return .terminateNow + #else + return .terminateCancel + #endif + } +} + +// MARK: - Codex placeholder (remove after user confirms real menu bar icon works) + +@MainActor +final class MenuBarPlaceholderController: NSObject { + static let shared = MenuBarPlaceholderController() + + private var windows: [NSWindow] = [] + private weak var context: AppContext? + private weak var oauthStore: OAuthStore? + private var popover: NSPopover? + private weak var highlightedButton: NSButton? + + private override init() { + super.init() + } + + func configure(context: AppContext, oauthStore: OAuthStore) { + self.context = context + self.oauthStore = oauthStore + } + + func install() { + guard context != nil, oauthStore != nil else { return } + windows.forEach { $0.close() } + windows = NSScreen.screens.map { screen in + let size = NSSize(width: 46, height: 22) + let frame = NSRect( + x: max(screen.frame.minX + 24, screen.frame.maxX - 760), + y: screen.frame.maxY - size.height - 2, + width: size.width, + height: size.height + ) + + let button = NSButton(frame: NSRect(origin: .zero, size: size)) + button.isBordered = false + button.wantsLayer = true + button.layer?.cornerRadius = 7 + button.layer?.masksToBounds = true + button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor + button.attributedTitle = NSAttributedString( + string: "CS", + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 13, weight: .bold), + .foregroundColor: NSColor.systemBlue + ] + ) + button.toolTip = "CoderSwitch (placeholder)" + button.setAccessibilityTitle("CoderSwitch") + button.setAccessibilityIdentifier("CoderSwitch.PlaceholderStatusItem") + button.target = self + button.action = #selector(togglePopover(_:)) + + let panel = NSPanel( + contentRect: frame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.contentView = button + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.level = .statusBar + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] + panel.orderFrontRegardless() + return panel + } + } + + @objc private func togglePopover(_ sender: NSButton) { + guard let context, let oauthStore else { return } + + if let popover, popover.isShown { + closePopover() + return + } + + let popover = StatusPopoverPresenter.makePopover( + context: context, + oauthStore: oauthStore, + delegate: self, + openSettings: { [weak self] in + self?.closePopover() + AppShell.shared.showSettingsWindow() + } + ) + self.popover = popover + highlightedButton = sender + sender.isHighlighted = true + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + } + + private func closePopover() { + popover?.close() + popover = nil + highlightedButton?.isHighlighted = false + highlightedButton = nil + } +} + +extension MenuBarPlaceholderController: NSPopoverDelegate { + func popoverWillClose(_ notification: Notification) { + popover = nil + highlightedButton?.isHighlighted = false + highlightedButton = nil + } +} + +@MainActor +final class AppShell: NSObject { + static let shared = AppShell() + + private var context: AppContext? + private var oauthStore: OAuthStore? + private var settingsWindow: NSWindow? + + private override init() {} + + func configure(context: AppContext, oauthStore: OAuthStore) { + self.context = context + self.oauthStore = oauthStore + NativeStatusItemController.shared.configure(context: context, oauthStore: oauthStore) + MenuBarPlaceholderController.shared.configure(context: context, oauthStore: oauthStore) + } + + func showSettingsWindow() { + guard let context, let oauthStore else { return } + + if let settingsWindow { + NSApp.setActivationPolicy(.regular) + settingsWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let controller = NSHostingController( + rootView: SettingsWindow() .environment(context.accountStore) .environment(context.proxySettings) .environment(context.proxyManager) .environment(context.quotaPoller) .environment(context.requestLogStore) .environment(oauthStore) - Divider() - Button("About CoderSwitch") { - NSApplication.shared.orderFrontStandardAboutPanel(options: [ - .applicationIcon: NSImage(named: "CoderSwitchAppIcon") ?? NSApplication.shared.applicationIconImage - ]) - } + ) + let window = NSWindow(contentViewController: controller) + window.title = "CoderSwitch Settings" + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + window.center() + settingsWindow = window + NSApp.setActivationPolicy(.regular) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +@MainActor +final class NativeStatusItemController: NSObject { + static let shared = NativeStatusItemController() + + private var statusItem: NSStatusItem? + private weak var context: AppContext? + private weak var oauthStore: OAuthStore? + private var popover: NSPopover? + private weak var highlightedButton: NSButton? + + private override init() { + super.init() + } + + func configure(context: AppContext, oauthStore: OAuthStore) { + self.context = context + self.oauthStore = oauthStore + } + + func install() { + guard context != nil, oauthStore != nil else { return } + + let item = statusItem ?? NSStatusBar.system.statusItem(withLength: 32) + statusItem = item + item.isVisible = true + item.length = 32 + + guard let button = item.button else { return } + button.image = nil + button.title = "CS" + button.toolTip = "CoderSwitch" + button.setAccessibilityTitle("CoderSwitch") + button.setAccessibilityIdentifier("CoderSwitch.NativeStatusItem") + button.target = self + button.action = #selector(togglePopover(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + } + + @objc private func togglePopover(_ sender: NSStatusBarButton) { + guard let context, let oauthStore else { return } + + if let popover, popover.isShown { + closePopover() + return } - .menuBarExtraStyle(.window) - Window("CoderSwitch Settings", id: "settings") { - SettingsWindow() + let popover = StatusPopoverPresenter.makePopover( + context: context, + oauthStore: oauthStore, + delegate: self, + openSettings: { [weak self] in + self?.closePopover() + AppShell.shared.showSettingsWindow() + } + ) + self.popover = popover + highlightedButton = sender + sender.isHighlighted = true + popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) + } + + private func closePopover() { + popover?.close() + popover = nil + highlightedButton?.isHighlighted = false + highlightedButton = nil + } +} + +extension NativeStatusItemController: NSPopoverDelegate { + func popoverWillClose(_ notification: Notification) { + popover = nil + highlightedButton?.isHighlighted = false + highlightedButton = nil + } +} + +@MainActor +private enum StatusPopoverPresenter { + static let contentSize = NSSize(width: 340, height: 720) + + static func makePopover( + context: AppContext, + oauthStore: OAuthStore, + delegate: NSPopoverDelegate, + openSettings: @escaping () -> Void + ) -> NSPopover { + let content = ScrollView { + StatusPopover(openSettings: openSettings) .environment(context.accountStore) .environment(context.proxySettings) .environment(context.proxyManager) @@ -33,10 +316,30 @@ struct CoderSwitchApp: App { .environment(context.requestLogStore) .environment(oauthStore) } - .windowResizability(.contentSize) + .frame(width: contentSize.width, height: contentSize.height) - Settings { - Text("CoderSwitch") - } + let hosting = NSHostingController(rootView: content) + hosting.preferredContentSize = contentSize + hosting.view.frame = NSRect(origin: .zero, size: contentSize) + + let popover = NSPopover() + popover.behavior = .transient + popover.animates = true + popover.contentSize = contentSize + popover.contentViewController = hosting + popover.delegate = delegate + return popover + } +} + +@MainActor +final class AppTerminationController { + static let shared = AppTerminationController() + private(set) var shouldAllowTermination = false + + private init() {} + + func allowTermination() { + shouldAllowTermination = true } } diff --git a/CoderSwitch/Database/Database.swift b/CoderSwitch/Database/Database.swift deleted file mode 100644 index b6e689c..0000000 --- a/CoderSwitch/Database/Database.swift +++ /dev/null @@ -1,98 +0,0 @@ -import Foundation -import GRDB - -/// Single SQLite database for CoderSwitch. -/// Lives at ~/Library/Application Support/CoderSwitch/coderswitch.sqlite. -/// All persistence (accounts, oauth, proxy, usage) goes through this. -@MainActor -final class AppDatabase { - static let shared = AppDatabase() - - let queue: DatabaseQueue - - private init() { - let dir = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("CoderSwitch", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let dbURL = dir.appendingPathComponent("coderswitch.sqlite") - - do { - self.queue = try DatabaseQueue(path: dbURL.path) - try Self.migrate(self.queue) - } catch { - fatalError("CoderSwitch: failed to open database at \(dbURL.path): \(error)") - } - } - - private static func migrate(_ queue: DatabaseQueue) throws { - var migrator = DatabaseMigrator() - - migrator.registerMigration("v1_initial") { db in - try db.execute(sql: """ - CREATE TABLE accounts ( - id TEXT PRIMARY KEY, - json TEXT NOT NULL, - api_key_encrypted BLOB, - created_at INTEGER NOT NULL - ) - """) - - try db.execute(sql: """ - CREATE TABLE oauth_accounts ( - id TEXT PRIMARY KEY, - json TEXT NOT NULL, - access_token_encrypted BLOB NOT NULL, - refresh_token_encrypted BLOB, - created_at INTEGER NOT NULL - ) - """) - - try db.execute(sql: """ - CREATE TABLE proxy_settings ( - id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), - port INTEGER NOT NULL, - admin_key TEXT NOT NULL, - auto_start INTEGER NOT NULL - ) - """) - } - - migrator.registerMigration("v2_oauth_id_token") { db in - try db.execute(sql: """ - ALTER TABLE oauth_accounts - ADD COLUMN id_token_encrypted BLOB - """) - } - - migrator.registerMigration("v3_request_logs") { db in - try db.execute(sql: """ - CREATE TABLE request_logs ( - id TEXT PRIMARY KEY, - timestamp INTEGER NOT NULL, - method TEXT NOT NULL, - path TEXT NOT NULL, - account_id TEXT, - account_label TEXT, - provider TEXT, - model TEXT, - upstream_model TEXT, - status_code INTEGER, - latency_ms INTEGER NOT NULL, - input_tokens INTEGER NOT NULL, - output_tokens INTEGER NOT NULL, - cache_read_tokens INTEGER NOT NULL, - cache_write_tokens INTEGER NOT NULL, - uncategorized_tokens INTEGER NOT NULL, - error_message TEXT - ) - """) - try db.execute(sql: """ - CREATE INDEX request_logs_timestamp_idx - ON request_logs(timestamp DESC) - """) - } - - try migrator.migrate(queue) - } -} diff --git a/CoderSwitch/Database/SecretBox.swift b/CoderSwitch/Database/SecretBox.swift deleted file mode 100644 index 9a498f8..0000000 --- a/CoderSwitch/Database/SecretBox.swift +++ /dev/null @@ -1,51 +0,0 @@ -import Foundation -import CryptoKit - -/// AES-GCM encryption for secrets stored in SQLite. -/// Master key lives at ~/Library/Application Support/CoderSwitch/.master.key (mode 0600). -/// Generated on first launch. No Keychain involved — user explicitly requested moving away from Keychain. -enum SecretBox { - private static let key: SymmetricKey = { - let dir = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("CoderSwitch", isDirectory: true) - try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let keyURL = dir.appendingPathComponent(".master.key") - - if let existing = try? Data(contentsOf: keyURL), existing.count == 32 { - return SymmetricKey(data: existing) - } - - let new = SymmetricKey(size: .bits256) - let raw = new.withUnsafeBytes { Data($0) } - try? raw.write(to: keyURL, options: [.atomic, .completeFileProtection]) - try? FileManager.default.setAttributes( - [.posixPermissions: 0o600], - ofItemAtPath: keyURL.path - ) - return new - }() - - static func seal(_ plaintext: String) throws -> Data { - let data = Data(plaintext.utf8) - let sealed = try AES.GCM.seal(data, using: key) - guard let combined = sealed.combined else { - throw SecretBoxError.encryptionFailed - } - return combined - } - - static func open(_ ciphertext: Data) throws -> String { - let sealed = try AES.GCM.SealedBox(combined: ciphertext) - let data = try AES.GCM.open(sealed, using: key) - guard let str = String(data: data, encoding: .utf8) else { - throw SecretBoxError.decryptionFailed - } - return str - } -} - -enum SecretBoxError: Error { - case encryptionFailed - case decryptionFailed -} diff --git a/CoderSwitch/Models/Account.swift b/CoderSwitch/Models/Account.swift deleted file mode 100644 index 3389647..0000000 --- a/CoderSwitch/Models/Account.swift +++ /dev/null @@ -1,222 +0,0 @@ -import Foundation - -struct Account: Identifiable, Codable, Hashable, Sendable { - let id: UUID - var label: String - var provider: Provider - var customEndpoint: String? - var externalID: String? - var defaultModel: String? - var availableModels: [ProviderModel]? - let createdAt: Date - var usageLimits: [UsageLimit] - var lastCheckedAt: Date? - var lastCheckError: String? - var tokenUsageBuckets: [TokenUsageBucket] - var circuitBreaker: CircuitBreakerConfig - var recentFailures: [Date] - var isEnabled: Bool - var menuBarUsageLimitIDs: Set? - - init( - id: UUID = UUID(), - label: String, - provider: Provider, - customEndpoint: String? = nil, - externalID: String? = nil, - defaultModel: String? = nil, - availableModels: [ProviderModel]? = nil, - createdAt: Date = Date(), - usageLimits: [UsageLimit] = [], - lastCheckedAt: Date? = nil, - lastCheckError: String? = nil, - tokenUsageBuckets: [TokenUsageBucket] = [], - circuitBreaker: CircuitBreakerConfig = .init(), - recentFailures: [Date] = [], - isEnabled: Bool = true, - menuBarUsageLimitIDs: Set? = nil - ) { - self.id = id - self.label = label - self.provider = provider - self.customEndpoint = customEndpoint - self.externalID = externalID - self.defaultModel = defaultModel - self.availableModels = availableModels - self.createdAt = createdAt - self.usageLimits = usageLimits - self.lastCheckedAt = lastCheckedAt - self.lastCheckError = lastCheckError - self.tokenUsageBuckets = tokenUsageBuckets - self.circuitBreaker = circuitBreaker - self.recentFailures = recentFailures - self.isEnabled = isEnabled - self.menuBarUsageLimitIDs = menuBarUsageLimitIDs - } - - var endpoint: String { - customEndpoint?.trimmingCharacters(in: .whitespaces).nonEmpty - ?? provider.defaultEndpoint - } - - func tokenUsageTotal( - period: TokenUsagePeriod, - now: Date = Date(), - calendar: Calendar = .current - ) -> TokenUsageDelta { - tokenUsageBuckets.reduce(into: TokenUsageDelta()) { total, bucket in - guard period.contains(dayKey: bucket.day, now: now, calendar: calendar) else { return } - total.add(bucket.totals) - } - } - - func tokenUsageTotalsByModel( - period: TokenUsagePeriod, - now: Date = Date(), - calendar: Calendar = .current - ) -> [TokenUsageGroupTotal] { - var totals: [String: TokenUsageDelta] = [:] - for bucket in tokenUsageBuckets where period.contains(dayKey: bucket.day, now: now, calendar: calendar) { - totals[bucket.model, default: TokenUsageDelta()].add(bucket.totals) - } - return totals - .filter { !$0.value.isEmpty } - .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } - .map { TokenUsageGroupTotal(id: $0.key, label: $0.key, totals: $0.value) } - } - - mutating func recordTokenUsage(_ event: TokenUsageEvent) { - let model = event.model.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "unknown" - let day = TokenUsagePeriod.dayKey(for: event.occurredAt) - if let idx = tokenUsageBuckets.firstIndex(where: { $0.day == day && $0.model == model }) { - tokenUsageBuckets[idx].totals.add(event.usage) - } else { - tokenUsageBuckets.append(TokenUsageBucket(day: day, model: model, totals: event.usage)) - } - } - - enum CodingKeys: String, CodingKey { - case id - case label - case provider - case customEndpoint - case externalID - case defaultModel - case availableModels - case createdAt - case usageLimits - case lastCheckedAt - case lastCheckError - case tokenUsageBuckets - case circuitBreaker - case recentFailures - case isEnabled - case menuBarUsageLimitIDs - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - label = try container.decode(String.self, forKey: .label) - let decodedProvider = try Self.decodeProvider(from: container) - provider = decodedProvider.provider - customEndpoint = try container.decodeIfPresent(String.self, forKey: .customEndpoint) - ?? decodedProvider.legacyEndpoint - externalID = try container.decodeIfPresent(String.self, forKey: .externalID) - defaultModel = try container.decodeIfPresent(String.self, forKey: .defaultModel) - availableModels = try container.decodeIfPresent([ProviderModel].self, forKey: .availableModels) - createdAt = try container.decode(Date.self, forKey: .createdAt) - let decodedUsageLimits = try container.decodeIfPresent([UsageLimit].self, forKey: .usageLimits) ?? [] - usageLimits = provider == .miniMax - ? decodedUsageLimits.map(Self.normalizeLegacyMiniMaxLimit) - : decodedUsageLimits - lastCheckedAt = try container.decodeIfPresent(Date.self, forKey: .lastCheckedAt) - lastCheckError = try container.decodeIfPresent(String.self, forKey: .lastCheckError) - tokenUsageBuckets = try container.decodeIfPresent([TokenUsageBucket].self, forKey: .tokenUsageBuckets) ?? [] - circuitBreaker = try container.decodeIfPresent(CircuitBreakerConfig.self, forKey: .circuitBreaker) ?? CircuitBreakerConfig() - recentFailures = try container.decodeIfPresent([Date].self, forKey: .recentFailures) ?? [] - isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true - menuBarUsageLimitIDs = try container.decodeIfPresent(Set.self, forKey: .menuBarUsageLimitIDs) - } - - var menuBarUsageLimits: [UsageLimit] { - guard provider == .googleAntigravity else { return usageLimits } - guard let menuBarUsageLimitIDs else { - return Array(usageLimits.filter { !$0.isGoogleAntigravityInternalModel }.prefix(3)) - } - return usageLimits.filter { menuBarUsageLimitIDs.contains($0.id) && !$0.isGoogleAntigravityInternalModel } - } - - /// Whether to show a warning to the user — never blocks routing. - func shouldWarnAboutFailures(now: Date = Date()) -> Bool { - guard circuitBreaker.enabled else { return false } - let cutoff = now.addingTimeInterval(-circuitBreaker.windowSeconds) - return recentFailures.filter { $0 >= cutoff }.count >= circuitBreaker.failureThreshold - } - - mutating func recordFailure(at date: Date = Date()) { - let cutoff = date.addingTimeInterval(-circuitBreaker.windowSeconds) - recentFailures.append(date) - recentFailures.removeAll { $0 < cutoff } - } - - mutating func clearFailures() { - recentFailures.removeAll() - } - - private static func normalizeLegacyMiniMaxLimit(_ limit: UsageLimit) -> UsageLimit { - guard limit.valueKind != "minimaxRemainingNormalized", - limit.name.hasPrefix("MiniMax-M"), - limit.unit?.contains("request") == true, - let used = limit.used, - let total = limit.limit, - total > 0 else { - return limit - } - - var normalized = limit - if used > total - used { - normalized.used = max(0, total - used) - } - normalized.valueKind = "minimaxRemainingNormalized" - return normalized - } - - private static func decodeProvider( - from container: KeyedDecodingContainer - ) throws -> (provider: Provider, legacyEndpoint: String?) { - let rawProvider = try container.decode(String.self, forKey: .provider) - if let provider = Provider(rawValue: rawProvider) { - return (provider, nil) - } - - switch rawProvider { - case "ikunCode": - return (.openAICompatible, "https://api.ikuncode.cc/v1") - case "fishXCode": - return (.openAICompatible, "https://api.fishxcode.com/v1") - default: - throw DecodingError.dataCorruptedError( - forKey: .provider, - in: container, - debugDescription: "Unsupported provider '\(rawProvider)'" - ) - } - } -} - -struct CircuitBreakerConfig: Codable, Hashable, Sendable { - var enabled: Bool - var failureThreshold: Int - var windowSeconds: TimeInterval - - init(enabled: Bool = false, failureThreshold: Int = 5, windowSeconds: TimeInterval = 300) { - self.enabled = enabled - self.failureThreshold = failureThreshold - self.windowSeconds = windowSeconds - } -} - -private extension String { - var nonEmpty: String? { isEmpty ? nil : self } -} diff --git a/CoderSwitch/Models/OAuthAccount.swift b/CoderSwitch/Models/OAuthAccount.swift deleted file mode 100644 index e5f2f6f..0000000 --- a/CoderSwitch/Models/OAuthAccount.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -enum OAuthAccountAuthSource: String, Codable, Hashable, Sendable { - case oauth - case json - - var displayName: String { rawValue.uppercased() } -} - -struct OAuthAccount: Identifiable, Codable, Hashable, Sendable { - let id: UUID - var label: String - var provider: OAuthProvider - var email: String? - var externalID: String? - let createdAt: Date - var token: OAuthToken - var authSource: OAuthAccountAuthSource - var lastCheckedAt: Date? - var lastCheckError: String? - - init( - id: UUID = UUID(), - label: String, - provider: OAuthProvider, - email: String? = nil, - externalID: String? = nil, - token: OAuthToken, - createdAt: Date = Date(), - authSource: OAuthAccountAuthSource = .oauth, - lastCheckedAt: Date? = nil, - lastCheckError: String? = nil - ) { - self.id = id - self.label = label - self.provider = provider - self.email = email - self.externalID = externalID - self.token = token - self.createdAt = createdAt - self.authSource = authSource - self.lastCheckedAt = lastCheckedAt - self.lastCheckError = lastCheckError - } - - enum CodingKeys: String, CodingKey { - case id - case label - case provider - case email - case externalID - case createdAt - case token - case authSource - case lastCheckedAt - case lastCheckError - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(UUID.self, forKey: .id) - label = try container.decode(String.self, forKey: .label) - provider = try container.decode(OAuthProvider.self, forKey: .provider) - email = try container.decodeIfPresent(String.self, forKey: .email) - externalID = try container.decodeIfPresent(String.self, forKey: .externalID) - createdAt = try container.decode(Date.self, forKey: .createdAt) - token = try container.decode(OAuthToken.self, forKey: .token) - authSource = try container.decodeIfPresent(OAuthAccountAuthSource.self, forKey: .authSource) ?? .oauth - lastCheckedAt = try container.decodeIfPresent(Date.self, forKey: .lastCheckedAt) - lastCheckError = try container.decodeIfPresent(String.self, forKey: .lastCheckError) - } -} diff --git a/CoderSwitch/Models/OAuthProvider.swift b/CoderSwitch/Models/OAuthProvider.swift deleted file mode 100644 index 7e42dd4..0000000 --- a/CoderSwitch/Models/OAuthProvider.swift +++ /dev/null @@ -1,126 +0,0 @@ -import Foundation - -enum OAuthProvider: String, CaseIterable, Codable, Identifiable, Hashable, Sendable { - case codex - case gemini - - var id: String { rawValue } - - var displayName: String { - switch self { - case .codex: return "Codex" - case .gemini: return "Google Antigravity" - } - } - - var iconName: String { - switch self { - case .codex: return "chevron.left.forwardslash.chevron.right" - case .gemini: return "sparkles" - } - } - - var authURL: URL { - switch self { - case .codex: - return URL(string: "https://auth.openai.com/oauth/authorize")! - case .gemini: - return URL(string: "https://accounts.google.com/o/oauth2/v2/auth")! - } - } - - var tokenURL: URL { - switch self { - case .codex: - return URL(string: "https://auth.openai.com/oauth/token")! - case .gemini: - return URL(string: "https://oauth2.googleapis.com/token")! - } - } - - var userInfoURL: URL { - switch self { - case .codex: - return URL(string: "https://api.openai.com/v1/profile")! - case .gemini: - return URL(string: "https://www.googleapis.com/oauth2/v2/userinfo")! - } - } - - var scopes: String { - switch self { - case .codex: - return "openid email profile offline_access" - case .gemini: - return "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile" - } - } - - var callbackPath: String { - switch self { - case .codex: return "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/auth/callback" - case .gemini: return "/oauth2callback" - } - } - - var callbackPort: UInt16 { - switch self { - case .codex: return 1455 - case .gemini: return 8085 - } - } - - var clientID: String { - switch self { - case .codex: - return "app_EMoamEEZ73f0CkXaXp7hrann" - case .gemini: - return "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com" - } - } - - var clientSecret: String? { - switch self { - case .codex: - return nil - case .gemini: - return Self.configuredSecret( - environmentKey: "CODERSWITCH_GOOGLE_OAUTH_CLIENT_SECRET", - infoDictionaryKey: "CoderSwitchGoogleOAuthClientSecret" - ) - } - } - - var originator: String { - switch self { - case .codex: - return "codex_chatgpt_desktop" - case .gemini: - return "" - } - } - - var requiresClientSecret: Bool { - clientSecret != nil - } -} - -private extension OAuthProvider { - static func configuredSecret(environmentKey: String, infoDictionaryKey: String) -> String? { - if let value = ProcessInfo.processInfo.environment[environmentKey]?.trimmedNonEmptySecret { - return value - } - - return (Bundle.main.object(forInfoDictionaryKey: infoDictionaryKey) as? String)?.trimmedNonEmptySecret - } -} - -private extension String { - var trimmedNonEmptySecret: String? { - let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, !trimmed.hasPrefix("$(") else { - return nil - } - return trimmed - } -} diff --git a/CoderSwitch/Models/OAuthToken.swift b/CoderSwitch/Models/OAuthToken.swift deleted file mode 100644 index 692bb33..0000000 --- a/CoderSwitch/Models/OAuthToken.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -struct OAuthToken: Codable, Sendable, Equatable, Hashable { - let accessToken: String - let refreshToken: String? - let expiresAt: Date? - let scope: String? - let idToken: String? - - init( - accessToken: String, - refreshToken: String?, - expiresAt: Date?, - scope: String?, - idToken: String? = nil - ) { - self.accessToken = accessToken - self.refreshToken = refreshToken - self.expiresAt = expiresAt - self.scope = scope - self.idToken = idToken - } - - var isExpired: Bool { - guard let expiresAt else { return false } - return Date() >= expiresAt - } - - var isNearExpiry: Bool { - guard let expiresAt else { return false } - return Date().addingTimeInterval(60 * 5) >= expiresAt - } -} - -struct OAuthTokenResponse: Decodable { - let accessToken: String - let refreshToken: String? - let expiresIn: Int? - let scope: String? - let idToken: String? - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case expiresIn = "expires_in" - case scope - case idToken = "id_token" - } - - func toOAuthToken(fallbackRefreshToken: String? = nil, fallbackIDToken: String? = nil) -> OAuthToken { - OAuthToken( - accessToken: accessToken, - refreshToken: refreshToken ?? fallbackRefreshToken, - expiresAt: expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) }, - scope: scope, - idToken: idToken ?? fallbackIDToken - ) - } -} diff --git a/CoderSwitch/Models/PKCEChallenge.swift b/CoderSwitch/Models/PKCEChallenge.swift deleted file mode 100644 index af4f563..0000000 --- a/CoderSwitch/Models/PKCEChallenge.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation -import CryptoKit - -struct PKCEChallenge { - let verifier: String - let challenge: String - let method: String = "S256" - - init() { - let size = 32 - var bytes = [UInt8](repeating: 0, count: size) - _ = SecRandomCopyBytes(kSecRandomDefault, size, &bytes) - verifier = Data(bytes).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - let hash = Data(SHA256.hash(data: Data(verifier.utf8))) - challenge = hash.base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } -} \ No newline at end of file diff --git a/CoderSwitch/Models/Provider.swift b/CoderSwitch/Models/Provider.swift deleted file mode 100644 index 82c0565..0000000 --- a/CoderSwitch/Models/Provider.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation - -enum APICompatibility: String, Codable, Hashable, Sendable { - case openAI - case anthropic -} - -enum Provider: String, CaseIterable, Codable, Identifiable, Hashable, Sendable { - case codex - case openAI - case anthropic - case openRouter - case miniMax - case openAICompatible - case anthropicCompatible - case googleAntigravity - - var id: String { rawValue } - - var displayName: String { - switch self { - case .codex: "Codex" - case .openAI: "OpenAI" - case .anthropic: "Anthropic" - case .openRouter: "OpenRouter" - case .miniMax: "MiniMax" - case .openAICompatible: "OpenAI-compatible (custom)" - case .anthropicCompatible: "Anthropic-compatible (custom)" - case .googleAntigravity: "Google Antigravity" - } - } - - var defaultEndpoint: String { - switch self { - case .codex: "https://chatgpt.com/backend-api" - case .openAI: "https://api.openai.com/v1" - case .anthropic: "https://api.anthropic.com" - case .openRouter: "https://openrouter.ai/api/v1" - case .miniMax: "https://api.minimax.io/v1" - case .googleAntigravity: "https://generativelanguage.googleapis.com/v1beta" - case .openAICompatible, .anthropicCompatible: "" - } - } - - var requiresCustomEndpoint: Bool { - switch self { - case .openAICompatible, .anthropicCompatible: true - default: false - } - } - - var compatibility: APICompatibility { - switch self { - case .codex, .openAI, .openRouter, .miniMax, .openAICompatible, .googleAntigravity: .openAI - case .anthropic, .anthropicCompatible: .anthropic - } - } - - var canAddWithAPIKey: Bool { - self != .codex && self != .googleAntigravity - } - - var isProxyRoutable: Bool { - self != .codex && self != .googleAntigravity - } - - var isClaudeCodeCompatible: Bool { - isProxyRoutable && compatibility == .anthropic - } -} diff --git a/CoderSwitch/Models/ProviderModel.swift b/CoderSwitch/Models/ProviderModel.swift deleted file mode 100644 index 0f08ecc..0000000 --- a/CoderSwitch/Models/ProviderModel.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -struct ProviderModel: Codable, Hashable, Identifiable, Sendable { - let id: String - var name: String? - var ownedBy: String? - - var displayName: String { - name?.nonEmpty ?? id - } -} - -private extension String { - var nonEmpty: String? { isEmpty ? nil : self } -} diff --git a/CoderSwitch/Models/ProviderModelFetcher.swift b/CoderSwitch/Models/ProviderModelFetcher.swift deleted file mode 100644 index 468b7e7..0000000 --- a/CoderSwitch/Models/ProviderModelFetcher.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -enum ProviderModelFetchError: LocalizedError { - case missingAPIKey - case httpStatus(Int) - case unparseableResponse - - var errorDescription: String? { - switch self { - case .missingAPIKey: "no API key" - case .httpStatus(let status): "HTTP \(status)" - case .unparseableResponse: "could not parse models response" - } - } -} - -struct ProviderModelFetcher: Sendable { - func fetchModels(account: Account, apiKey: String?) async throws -> [ProviderModel] { - guard let apiKey, !apiKey.isEmpty else { - throw ProviderModelFetchError.missingAPIKey - } - let url = try modelsURL(endpoint: account.endpoint) - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.timeoutInterval = 20 - request.setValue("application/json", forHTTPHeaderField: "Accept") - - switch account.provider.compatibility { - case .openAI: - request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - case .anthropic: - request.setValue(apiKey, forHTTPHeaderField: "x-api-key") - request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") - } - - let (data, response) = try await URLSession.shared.data(for: request) - let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard status == 200 else { - throw ProviderModelFetchError.httpStatus(status) - } - let models = try parseModels(data) - guard !models.isEmpty else { - throw ProviderModelFetchError.unparseableResponse - } - return models.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending } - } - - private func modelsURL(endpoint: String) throws -> URL { - var base = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) - while base.hasSuffix("/") { base.removeLast() } - guard let url = URL(string: base + "/models") else { - throw URLError(.badURL) - } - return url - } - - private func parseModels(_ data: Data) throws -> [ProviderModel] { - guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let entries = root["data"] as? [[String: Any]] else { - throw ProviderModelFetchError.unparseableResponse - } - - return entries.compactMap { entry in - guard let id = entry["id"] as? String, !id.isEmpty else { return nil } - let name = entry["name"] as? String - ?? entry["display_name"] as? String - let ownedBy = entry["owned_by"] as? String - ?? entry["ownedBy"] as? String - return ProviderModel(id: id, name: name, ownedBy: ownedBy) - } - } -} diff --git a/CoderSwitch/Models/ProxyRequestLog.swift b/CoderSwitch/Models/ProxyRequestLog.swift deleted file mode 100644 index b3748b9..0000000 --- a/CoderSwitch/Models/ProxyRequestLog.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -struct ProxyRequestLog: Identifiable, Hashable, Sendable { - let id: UUID - let timestamp: Date - let method: String - let path: String - let accountID: UUID? - let accountLabel: String? - let provider: Provider? - let model: String? - let upstreamModel: String? - let statusCode: Int? - let latencyMS: Int - let usage: TokenUsageDelta - let errorMessage: String? - - init( - id: UUID = UUID(), - timestamp: Date = Date(), - method: String, - path: String, - accountID: UUID?, - accountLabel: String?, - provider: Provider?, - model: String?, - upstreamModel: String?, - statusCode: Int?, - latencyMS: Int, - usage: TokenUsageDelta = TokenUsageDelta(), - errorMessage: String? = nil - ) { - self.id = id - self.timestamp = timestamp - self.method = method - self.path = path - self.accountID = accountID - self.accountLabel = accountLabel - self.provider = provider - self.model = model - self.upstreamModel = upstreamModel - self.statusCode = statusCode - self.latencyMS = latencyMS - self.usage = usage - self.errorMessage = errorMessage - } - - var statusSummary: String { - if let statusCode { - return "\(statusCode)" - } - return errorMessage == nil ? "pending" : "failed" - } - - var routeSummary: String { - let account = accountLabel ?? provider?.displayName ?? "Unrouted" - guard let upstreamModel, !upstreamModel.isEmpty else { return account } - return "\(account) / \(upstreamModel)" - } -} diff --git a/CoderSwitch/Models/QuotaCheck.swift b/CoderSwitch/Models/QuotaCheck.swift deleted file mode 100644 index 135f2d8..0000000 --- a/CoderSwitch/Models/QuotaCheck.swift +++ /dev/null @@ -1,263 +0,0 @@ -import Foundation - -/// Describes how to fetch usage/quota for a provider. nil = no public quota endpoint we trust. -struct QuotaCheck: Sendable { - let path: String - let headers: [String: String] - let makeURL: @Sendable (_ endpoint: String, _ path: String) throws -> URL - /// Parses a `(data, statusCode)` pair into UsageLimits. - let parse: @Sendable (Data, Int) throws -> [UsageLimit] - - init( - path: String, - headers: [String: String] = [:], - makeURL: @escaping @Sendable (_ endpoint: String, _ path: String) throws -> URL = QuotaCheck.defaultURL, - parse: @escaping @Sendable (Data, Int) throws -> [UsageLimit] - ) { - self.path = path - self.headers = headers - self.makeURL = makeURL - self.parse = parse - } - - func url(forEndpoint endpoint: String) throws -> URL { - try makeURL(endpoint, path) - } -} - -extension Provider { - /// Slug used as a model prefix to route to this provider type. - /// Duplicated in ModelRouter; this version is the canonical home. - var quotaCheck: QuotaCheck? { - switch self { - case .openRouter: return .openRouter - case .miniMax: return .miniMax - default: return nil - } - } -} - -extension QuotaCheck { - static func defaultURL(endpoint: String, path: String) throws -> URL { - var trimmed = endpoint - while trimmed.hasSuffix("/") { trimmed.removeLast() } - let normalizedPath = path.hasPrefix("/") ? path : "/" + path - guard let url = URL(string: trimmed + normalizedPath) else { - throw URLError(.badURL) - } - return url - } - - static let openRouter = QuotaCheck( - path: "/credits", - parse: { data, status in - guard status == 200 else { - throw QuotaCheckError.httpStatus(status) - } - let decoded = try JSONDecoder().decode(OpenRouterCreditsResponse.self, from: data) - let d = decoded.data - let balance = d.total_credits - d.total_usage - return [ - UsageLimit( - name: "Balance", - used: balance, - limit: nil, - valuePrefix: "$", - unit: nil, - resetAt: nil, - isPayAsYouGo: true - ) - ] - } - ) - - static let miniMax = QuotaCheck( - path: "/v1/token_plan/remains", - headers: [ - "Accept": "application/json, text/plain, */*", - "Referer": "https://platform.minimax.io/user-center/payment/token-plan" - ], - makeURL: { endpoint, path in - guard var components = URLComponents(string: endpoint.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw URLError(.badURL) - } - components.path = path - components.query = nil - components.fragment = nil - guard let url = components.url else { - throw URLError(.badURL) - } - return url - }, - parse: { data, status in - guard status == 200 else { - throw QuotaCheckError.httpStatus(status) - } - let decoded = try JSONDecoder().decode(MiniMaxRemainsResponse.self, from: data) - if let baseResp = decoded.base_resp, let code = baseResp.status_code, code != 0 { - throw QuotaCheckError.providerMessage(baseResp.status_msg ?? "MiniMax API error \(code)") - } - - var limits: [UsageLimit] = [] - for remain in decoded.model_remains ?? [] { - guard let modelName = remain.model_name?.nonEmpty, - modelName.hasPrefix("MiniMax-M") else { continue } - appendCountLimit( - to: &limits, - name: modelName, - total: remain.current_interval_total_count, - usedCandidates: [ - remain.current_interval_used_count, - remain.current_interval_usage_count - ], - remainingCandidates: [ - remain.current_interval_remaining_count, - remain.current_interval_remains_count - ], - resetAt: remain.resetAt - ) - appendCountLimit( - to: &limits, - name: "\(modelName) weekly", - total: remain.weekly_total_count ?? remain.current_week_total_count, - usedCandidates: [ - remain.weekly_used_count, - remain.weekly_usage_count, - remain.current_week_used_count, - remain.current_week_usage_count - ], - remainingCandidates: [ - remain.weekly_remaining_count, - remain.weekly_remains_count, - remain.current_week_remaining_count, - remain.current_week_remains_count - ], - resetAt: remain.weeklyResetAt - ) - } - - guard !limits.isEmpty else { - throw QuotaCheckError.unparseableResponse - } - return limits - } - ) - - private static func appendCountLimit( - to limits: inout [UsageLimit], - name: String, - total: Double?, - usedCandidates: [Double?], - remainingCandidates: [Double?], - resetAt: Date? - ) { - guard let total else { return } - let used: Double - if let directUsed = usedCandidates.compactMap({ $0 }).first { - used = directUsed - } else if let remaining = remainingCandidates.compactMap({ $0 }).first { - used = max(0, total - remaining) - } else { - return - } - limits.append(UsageLimit( - name: name, - used: max(0, used), - limit: total, - unit: " requests", - resetAt: resetAt, - valueKind: "minimaxRemainingNormalized" - )) - } -} - -enum QuotaCheckError: LocalizedError { - case httpStatus(Int) - case missingAPIKey - case providerMessage(String) - case unparseableResponse - - var errorDescription: String? { - switch self { - case .httpStatus(let s): return "HTTP \(s)" - case .missingAPIKey: return "no API key" - case .providerMessage(let message): return message - case .unparseableResponse: return "could not parse quota response" - } - } -} - -private struct OpenRouterCreditsResponse: Decodable { - struct Data: Decodable { - let total_credits: Double - let total_usage: Double - } - let data: Data -} - -private struct MiniMaxRemainsResponse: Decodable { - let model_remains: [MiniMaxModelRemain]? - let base_resp: MiniMaxBaseResponse? -} - -private struct MiniMaxBaseResponse: Decodable { - let status_code: Int? - let status_msg: String? -} - -private struct MiniMaxModelRemain: Decodable { - let model_name: String? - let start_time: Double? - let end_time: Double? - let remains_time: Double? - - let current_interval_total_count: Double? - let current_interval_usage_count: Double? - let current_interval_remaining_count: Double? - let current_interval_remains_count: Double? - let current_interval_used_count: Double? - - let weekly_total_count: Double? - let weekly_usage_count: Double? - let weekly_remaining_count: Double? - let weekly_remains_count: Double? - let weekly_used_count: Double? - let weekly_end_time: Double? - let weekly_reset_time: Double? - - let current_week_total_count: Double? - let current_week_usage_count: Double? - let current_week_remaining_count: Double? - let current_week_remains_count: Double? - let current_week_used_count: Double? - let current_week_end_time: Double? - let current_week_reset_time: Double? - - var resetAt: Date? { - Self.date(fromEpoch: end_time) - ?? Self.date(fromDurationFromNow: remains_time) - } - - var weeklyResetAt: Date? { - Self.date(fromEpoch: weekly_reset_time) - ?? Self.date(fromEpoch: weekly_end_time) - ?? Self.date(fromEpoch: current_week_reset_time) - ?? Self.date(fromEpoch: current_week_end_time) - } - - private static func date(fromEpoch value: Double?) -> Date? { - guard let value, value > 0 else { return nil } - let seconds = value > 100_000_000_000 ? value / 1000 : value - return Date(timeIntervalSince1970: seconds) - } - - private static func date(fromDurationFromNow value: Double?) -> Date? { - guard let value, value > 0 else { return nil } - let seconds = value > 100_000 ? value / 1000 : value - return Date(timeIntervalSinceNow: seconds) - } -} - -private extension String { - var nonEmpty: String? { isEmpty ? nil : self } -} diff --git a/CoderSwitch/Models/TokenUsage.swift b/CoderSwitch/Models/TokenUsage.swift deleted file mode 100644 index 3daf8c7..0000000 --- a/CoderSwitch/Models/TokenUsage.swift +++ /dev/null @@ -1,218 +0,0 @@ -import Foundation - -struct TokenUsageDelta: Codable, Hashable, Sendable { - var requests: Int - var inputTokens: Int - var outputTokens: Int - var cacheReadTokens: Int - var cacheWriteTokens: Int - var uncategorizedTokens: Int - - init( - requests: Int = 0, - inputTokens: Int = 0, - outputTokens: Int = 0, - cacheReadTokens: Int = 0, - cacheWriteTokens: Int = 0, - uncategorizedTokens: Int = 0 - ) { - self.requests = requests - self.inputTokens = inputTokens - self.outputTokens = outputTokens - self.cacheReadTokens = cacheReadTokens - self.cacheWriteTokens = cacheWriteTokens - self.uncategorizedTokens = uncategorizedTokens - } - - var totalTokens: Int { - inputTokens + outputTokens + uncategorizedTokens - } - - var isEmpty: Bool { - requests == 0 - && inputTokens == 0 - && outputTokens == 0 - && cacheReadTokens == 0 - && cacheWriteTokens == 0 - && uncategorizedTokens == 0 - } - - mutating func add(_ other: TokenUsageDelta) { - requests += other.requests - inputTokens += other.inputTokens - outputTokens += other.outputTokens - cacheReadTokens += other.cacheReadTokens - cacheWriteTokens += other.cacheWriteTokens - uncategorizedTokens += other.uncategorizedTokens - } - - mutating func mergeMax(_ other: TokenUsageDelta) { - inputTokens = max(inputTokens, other.inputTokens) - outputTokens = max(outputTokens, other.outputTokens) - cacheReadTokens = max(cacheReadTokens, other.cacheReadTokens) - cacheWriteTokens = max(cacheWriteTokens, other.cacheWriteTokens) - uncategorizedTokens = max(uncategorizedTokens, other.uncategorizedTokens) - } -} - -struct TokenUsageBucket: Codable, Hashable, Identifiable, Sendable { - var id: String { "\(day)|\(model)" } - var day: String - var model: String - var totals: TokenUsageDelta -} - -struct TokenUsageEvent: Hashable, Sendable { - let accountID: UUID - let provider: Provider - let model: String - let occurredAt: Date - let usage: TokenUsageDelta -} - -enum TokenUsagePeriod: String, CaseIterable, Identifiable { - case day - case week - case month - case year - case allTime - - var id: String { rawValue } - - var displayName: String { - switch self { - case .day: "Today" - case .week: "This Week" - case .month: "This Month" - case .year: "This Year" - case .allTime: "All Time" - } - } - - func contains(dayKey: String, now: Date = Date(), calendar: Calendar = .current) -> Bool { - guard self != .allTime else { return true } - guard let date = Self.date(from: dayKey, calendar: calendar) else { return false } - switch self { - case .day: - return calendar.isDate(date, inSameDayAs: now) - case .week: - return calendar.dateInterval(of: .weekOfYear, for: now)?.contains(date) ?? false - case .month: - return calendar.isDate(date, equalTo: now, toGranularity: .month) - case .year: - return calendar.isDate(date, equalTo: now, toGranularity: .year) - case .allTime: - return true - } - } - - static func dayKey(for date: Date, calendar: Calendar = .current) -> String { - let components = calendar.dateComponents([.year, .month, .day], from: date) - return String( - format: "%04d-%02d-%02d", - components.year ?? 0, - components.month ?? 0, - components.day ?? 0 - ) - } - - private static func date(from dayKey: String, calendar: Calendar) -> Date? { - let parts = dayKey.split(separator: "-").compactMap { Int($0) } - guard parts.count == 3 else { return nil } - return calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) - } -} - -struct TokenUsageGroupTotal: Identifiable, Sendable { - let id: String - let label: String - let totals: TokenUsageDelta -} - -struct TokenUsageParser: Sendable { - static func parseJSONResponse(_ data: Data) -> TokenUsageDelta? { - guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil - } - return parseUsageEnvelope(object) - } - - static func parseSSEEvent(_ event: String) -> TokenUsageDelta? { - var best = TokenUsageDelta() - for line in event.split(whereSeparator: \.isNewline) { - let trimmed = String(line).trimmingCharacters(in: .whitespacesAndNewlines) - guard trimmed.hasPrefix("data:") else { continue } - let payload = trimmed.dropFirst(5).trimmingCharacters(in: .whitespacesAndNewlines) - guard payload != "[DONE]", - let data = payload.data(using: .utf8), - let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let usage = parseUsageEnvelope(object) else { - continue - } - best.mergeMax(usage) - } - return best.isEmpty ? nil : best - } - - private static func parseUsageEnvelope(_ object: [String: Any]) -> TokenUsageDelta? { - if let usage = object["usage"] as? [String: Any] { - return parseUsage(usage) - } - if let message = object["message"] as? [String: Any], - let usage = message["usage"] as? [String: Any] { - return parseUsage(usage) - } - if let response = object["response"] as? [String: Any], - let usage = response["usage"] as? [String: Any] { - return parseUsage(usage) - } - return nil - } - - private static func parseUsage(_ usage: [String: Any]) -> TokenUsageDelta? { - let input = intValue(usage["prompt_tokens"]) ?? intValue(usage["input_tokens"]) ?? 0 - let output = intValue(usage["completion_tokens"]) ?? intValue(usage["output_tokens"]) ?? 0 - let total = intValue(usage["total_tokens"]) - - let promptDetails = usage["prompt_tokens_details"] as? [String: Any] - let inputDetails = usage["input_token_details"] as? [String: Any] - ?? usage["input_tokens_details"] as? [String: Any] - let cacheRead = intValue(usage["cache_read_input_tokens"]) - ?? intValue(usage["cache_read_tokens"]) - ?? intValue(promptDetails?["cached_tokens"]) - ?? intValue(inputDetails?["cache_read"]) - ?? intValue(inputDetails?["cached_tokens"]) - ?? 0 - let cacheWrite = intValue(usage["cache_creation_input_tokens"]) - ?? intValue(usage["cache_write_tokens"]) - ?? intValue(promptDetails?["cache_creation_tokens"]) - ?? intValue(inputDetails?["cache_write"]) - ?? 0 - - let knownTotal = input + output - let uncategorized = input == 0 && output == 0 ? (total ?? 0) : max(0, (total ?? knownTotal) - knownTotal) - let delta = TokenUsageDelta( - inputTokens: input, - outputTokens: output, - cacheReadTokens: cacheRead, - cacheWriteTokens: cacheWrite, - uncategorizedTokens: uncategorized - ) - return delta.isEmpty ? nil : delta - } - - private static func intValue(_ value: Any?) -> Int? { - switch value { - case let int as Int: - int - case let double as Double: - Int(double) - case let number as NSNumber: - number.intValue - case let string as String: - Int(string) - default: - nil - } - } -} diff --git a/CoderSwitch/Models/UsageLimit.swift b/CoderSwitch/Models/UsageLimit.swift deleted file mode 100644 index cb33eca..0000000 --- a/CoderSwitch/Models/UsageLimit.swift +++ /dev/null @@ -1,118 +0,0 @@ -import Foundation - -/// A single quota dimension for an account (e.g. monthly credit, daily request count). -/// Optional fields stay nil when a provider only reports a subset. -struct UsageLimit: Codable, Hashable, Identifiable, Sendable { - var id: String { storageID } - var name: String - var storageID: String - var used: Double? - var limit: Double? - var valuePrefix: String? - var unit: String? - var resetAt: Date? - /// Marks quota values that have already been normalized into consumed usage. - /// Nil means the value came from older app data before this distinction existed. - var valueKind: String? = "used" - /// When true, this is a pay-as-you-go metric with no upper limit (no progress bar shown). - var isPayAsYouGo: Bool = false - - init( - name: String, - storageID: String? = nil, - used: Double? = nil, - limit: Double? = nil, - valuePrefix: String? = nil, - unit: String? = nil, - resetAt: Date? = nil, - valueKind: String? = "used", - isPayAsYouGo: Bool = false - ) { - self.name = name - self.storageID = storageID ?? name - self.used = used - self.limit = limit - self.valuePrefix = valuePrefix - self.unit = unit - self.resetAt = resetAt - self.valueKind = valueKind - self.isPayAsYouGo = isPayAsYouGo - } - - enum CodingKeys: String, CodingKey { - case name - case storageID - case used - case limit - case valuePrefix - case unit - case resetAt - case valueKind - case isPayAsYouGo - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - name = try container.decode(String.self, forKey: .name) - storageID = try container.decodeIfPresent(String.self, forKey: .storageID) ?? name - used = try container.decodeIfPresent(Double.self, forKey: .used) - limit = try container.decodeIfPresent(Double.self, forKey: .limit) - valuePrefix = try container.decodeIfPresent(String.self, forKey: .valuePrefix) - unit = try container.decodeIfPresent(String.self, forKey: .unit) - resetAt = try container.decodeIfPresent(Date.self, forKey: .resetAt) - valueKind = try container.decodeIfPresent(String.self, forKey: .valueKind) ?? "used" - isPayAsYouGo = try container.decodeIfPresent(Bool.self, forKey: .isPayAsYouGo) ?? false - } - - var fraction: Double? { - guard let used, let limit, limit > 0, !isPayAsYouGo else { return nil } - if valueKind == "googleAntigravityRemainingPercent" { - return min(1.0, max(0.0, 1.0 - (used / limit))) - } - return min(1.0, max(0.0, used / limit)) - } - - var remainingDescription: String? { - if isPayAsYouGo { - guard let used else { return nil } - return formatValue(used) - } - if valueKind == "googleAntigravityRemainingPercent", - let used, - let limit { - let remaining = min(limit, max(0, used)) - let consumed = max(0, limit - remaining) - return "\(formatValue(consumed))% used" - } - guard let limit else { return nil } - let used = used ?? 0 - let unit = unit ?? "" - return "\(formatValue(used))\(unit) of \(formatValue(limit))\(unit)" - } - - func displayValue(_ value: Double) -> String { - formatValue(value) + (unit ?? "") - } - - private func format(_ v: Double) -> String { - if v >= 100 || v.truncatingRemainder(dividingBy: 1) == 0 { - return String(Int(v.rounded())) - } - return String(format: "%.2f", v) - } - - private func formatValue(_ v: Double) -> String { - (valuePrefix ?? "") + format(v) - } -} - -extension UsageLimit { - var isGoogleAntigravityInternalModel: Bool { - let identifiers = [name, storageID].map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - return identifiers.contains { value in - value.hasPrefix("model_placeholder") - || value.hasPrefix("chat_") - || value.hasPrefix("tab_") - } - } -} diff --git a/CoderSwitch/Proxy/ModelRouter.swift b/CoderSwitch/Proxy/ModelRouter.swift deleted file mode 100644 index 6f96cf5..0000000 --- a/CoderSwitch/Proxy/ModelRouter.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -/// Resolves an incoming request's model string to a backing Account. -/// -/// Model strings can be prefixed to disambiguate which account to use: -/// "openrouter/anthropic/claude-3-opus" → first OpenRouter account, model "anthropic/claude-3-opus" -/// "openrouter:work/anthropic/claude-3-opus" → OpenRouter account labeled "work" -/// "anthropic/claude-3-opus" → first Anthropic-compatible account, model "anthropic/claude-3-opus" -/// If no provider prefix is found, the first account whose default endpoint accepts the request is used. -struct ModelRouter { - let accounts: [Account] - - struct Resolution { - let account: Account - let upstreamModel: String - } - - func resolve( - model: String, - compatibility: APICompatibility, - preferredAccountID: UUID? = nil - ) -> Resolution? { - if let preferredAccountID, - let account = accounts.first(where: { $0.id == preferredAccountID }) { - return resolvePreferred(model: model, compatibility: compatibility, account: account) - } - - if let slash = model.firstIndex(of: "/") { - let prefix = String(model[.. Resolution? { - if let preferredAccountID, - let account = accounts.first(where: { - $0.id == preferredAccountID - && $0.isEnabled - && $0.provider.isProxyRoutable - && $0.provider.compatibility == compatibility - }) { - return Resolution(account: account, upstreamModel: account.defaultModel ?? "") - } - guard let account = accounts.first(where: { - $0.isEnabled && $0.provider.isProxyRoutable && $0.provider.compatibility == compatibility - }) else { - return nil - } - return Resolution(account: account, upstreamModel: account.defaultModel ?? "") - } - - private func resolvePreferred( - model: String, - compatibility: APICompatibility, - account: Account - ) -> Resolution? { - guard account.isEnabled, - account.provider.isProxyRoutable, - account.provider.compatibility == compatibility else { - return nil - } - - if let slash = model.firstIndex(of: "/") { - let prefix = String(model[.. Account? { - let parts = prefix.split(separator: ":", maxSplits: 1).map(String.init) - let providerSlug = parts[0].lowercased() - let labelHint = parts.count > 1 ? parts[1].lowercased() : nil - - let candidates = accounts.filter { account in - guard account.isEnabled else { return false } - guard account.provider.compatibility == compatibility else { return false } - guard account.provider.isProxyRoutable else { return false } - return account.provider.routingSlug == providerSlug - } - guard !candidates.isEmpty else { return nil } - if let labelHint { - return candidates.first { $0.label.lowercased() == labelHint } - ?? candidates.first { $0.label.lowercased().contains(labelHint) } - } - return candidates.first - } - - private func matches(account: Account, prefix: String, compatibility: APICompatibility) -> Bool { - let parts = prefix.split(separator: ":", maxSplits: 1).map(String.init) - let providerSlug = parts[0].lowercased() - let labelHint = parts.count > 1 ? parts[1].lowercased() : nil - guard account.isEnabled, - account.provider.compatibility == compatibility, - account.provider.isProxyRoutable, - account.provider.routingSlug == providerSlug else { - return false - } - guard let labelHint else { return true } - let label = account.label.lowercased() - return label == labelHint || label.contains(labelHint) - } -} - -extension Provider { - /// Slug used as a model prefix to route to this provider type. - var routingSlug: String { - switch self { - case .codex: "codex" - case .openAI: "openai-direct" - case .anthropic: "anthropic-direct" - case .openRouter: "openrouter" - case .miniMax: "minimax" - case .openAICompatible: "openai" - case .anthropicCompatible: "anthropic" - case .googleAntigravity: "antigravity" - } - } -} diff --git a/CoderSwitch/Proxy/ProxyManager.swift b/CoderSwitch/Proxy/ProxyManager.swift deleted file mode 100644 index 603d31d..0000000 --- a/CoderSwitch/Proxy/ProxyManager.swift +++ /dev/null @@ -1,144 +0,0 @@ -import Foundation -import Observation -import os - -private let proxyLog = Logger(subsystem: "com.coderswitch.proxy", category: "manager") - -@Observable -@MainActor -final class ProxyManager { - enum Status: Equatable { - case stopped - case starting - case running(port: Int) - case stopping - case failed(String) - - var isRunning: Bool { - if case .running = self { return true } - return false - } - } - - private(set) var status: Status = .stopped - - private let state = ProxyState() - private var task: Task? - private var usageRecorder: TokenUsageRecorder? - private var requestLogRecorder: RequestLogRecorder? - - private weak var accountStore: AccountStore? - private var settings: ProxySettings? - - func bind(accountStore: AccountStore, settings: ProxySettings, requestLogStore: RequestLogStore) { - guard self.accountStore !== accountStore else { return } - self.accountStore = accountStore - self.settings = settings - let usageRecorder = TokenUsageRecorder(store: accountStore) - let requestLogRecorder = RequestLogRecorder(store: requestLogStore) - self.usageRecorder = usageRecorder - self.requestLogRecorder = requestLogRecorder - Task { await self.state.setUsageRecorder(usageRecorder) } - Task { await self.state.setRequestLogRecorder(requestLogRecorder) } - Task { [weak accountStore] in - await self.state.setFailureRecorder { id in - Task { @MainActor in - accountStore?.recordFailure(accountID: id) - } - } - } - Task { await self.refreshState() } - observeChanges() - } - - private func observeChanges() { - guard let store = accountStore, let settings else { return } - withObservationTracking { - _ = store.accounts - _ = settings.adminKey - _ = settings.port - } onChange: { - Task { @MainActor [weak self] in - await self?.refreshState() - self?.observeChanges() - } - } - } - - func refreshState() async { - guard let store = accountStore, let settings = settings else { return } - let accounts = store.accounts - var keys: [UUID: String] = [:] - for account in accounts { - if let key = store.apiKey(for: account) { - keys[account.id] = key - } - } - let adminKey = settings.adminKey - await state.update(accounts: accounts, apiKeys: keys, adminKey: adminKey) - proxyLog.info("state refresh: accounts=\(accounts.count, privacy: .public) keyed=\(keys.count, privacy: .public) adminKeyConfigured=\(!adminKey.isEmpty, privacy: .public)") - } - - func start() { - guard let settings else { - proxyLog.error("start() called before bind() — refusing") - status = .failed("not bound") - return - } - if task != nil { - proxyLog.notice("start() ignored — task already running (status=\(String(describing: self.status), privacy: .public))") - return - } - let port = settings.port - proxyLog.info("start() requested on port \(port, privacy: .public)") - FileHandle.standardError.write(Data("[CoderSwitch] proxy starting on 127.0.0.1:\(port)\n".utf8)) - status = .starting - let server = ProxyServer(port: port, state: state) - task = Task { [weak self] in - FileHandle.standardError.write(Data("[CoderSwitch] task entered → calling runService on :\(port)\n".utf8)) - do { - proxyLog.info("hummingbird runService → bind 127.0.0.1:\(port, privacy: .public)") - try await server.runUntilCancelled() - FileHandle.standardError.write(Data("[CoderSwitch] runService returned cleanly\n".utf8)) - await MainActor.run { self?.status = .stopped } - } catch is CancellationError { - FileHandle.standardError.write(Data("[CoderSwitch] runService cancelled\n".utf8)) - await MainActor.run { self?.status = .stopped } - } catch { - let msg = "\(type(of: error)): \(error.localizedDescription)" - FileHandle.standardError.write(Data("[CoderSwitch] runService failed: \(msg)\n".utf8)) - await MainActor.run { self?.status = .failed(msg) } - } - } - Task { [weak self] in - try? await Task.sleep(for: .milliseconds(150)) - await MainActor.run { - guard let self else { return } - if case .starting = self.status { - self.status = .running(port: port) - proxyLog.info("status → running(port: \(port, privacy: .public))") - } - } - } - } - - func stop() { - guard let task else { - proxyLog.notice("stop() ignored — no running task") - return - } - proxyLog.info("stop() requested") - status = .stopping - task.cancel() - self.task = nil - } - - func restart() { - proxyLog.info("restart() requested") - stop() - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(200)) - self.start() - } - } -} diff --git a/CoderSwitch/Proxy/ProxyServer.swift b/CoderSwitch/Proxy/ProxyServer.swift deleted file mode 100644 index 3ba35a1..0000000 --- a/CoderSwitch/Proxy/ProxyServer.swift +++ /dev/null @@ -1,542 +0,0 @@ -import Foundation -import Hummingbird -import HTTPTypes -import NIOCore - -/// Hummingbird-based local proxy. Wraps an Application running on 127.0.0.1. -/// Lifecycle is managed by ProxyManager — this type only knows how to construct + run. -struct ProxyServer { - let port: Int - let state: ProxyState - let forwarder: UpstreamForwarder - - init(port: Int, state: ProxyState, forwarder: UpstreamForwarder = .init()) { - self.port = port - self.state = state - self.forwarder = forwarder - } - - func runUntilCancelled() async throws { - let router = Router() - let state = self.state - let forwarder = self.forwarder - - router.get("/healthz") { _, _ in - Response( - status: .ok, - headers: [.contentType: "application/json"], - body: .init(byteBuffer: ByteBuffer(string: #"{"ok":true}"#)) - ) - } - - router.get("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/v1/models") { request, _ in - try await Self.handleModels(request: request, state: state) - } - - router.post("/v1/chat/completions") { request, context in - try await Self.handleOpenAIChat(request: request, context: context, state: state, forwarder: forwarder) - } - - router.post("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/v1/responses") { request, context in - try await Self.handleOpenAIModelRequest( - request: request, - context: context, - state: state, - forwarder: forwarder, - upstreamPath: "/responses" - ) - } - - router.post("/v1/responses/compact") { request, context in - try await Self.handleOpenAIModelRequest( - request: request, - context: context, - state: state, - forwarder: forwarder, - upstreamPath: "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/responses/compact" - ) - } - - router.get("/v1/responses/*") { request, context in - try await Self.handleOpenAIPassthrough(request: request, context: context, state: state, forwarder: forwarder) - } - - router.post("/v1/responses/*") { request, context in - try await Self.handleOpenAIPassthrough(request: request, context: context, state: state, forwarder: forwarder) - } - - router.post("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/v1/messages") { request, context in - try await Self.handleAnthropicMessages(request: request, context: context, state: state, forwarder: forwarder) - } - - router.post("/v1/messages/count_tokens") { request, context in - try await Self.handleAnthropicMessages( - request: request, - context: context, - state: state, - forwarder: forwarder, - upstreamPath: "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/messages/count_tokens" - ) - } - - let app = Application( - router: router, - configuration: .init( - address: .hostname("127.0.0.1", port: port), - serverName: "CoderSwitch" - ) - ) - try await app.runService() - } - - // MARK: handlers - - private static func handleModels( - request: Request, - state: ProxyState - ) async throws -> Response { - let snap = await state.snapshot() - _ = try authorize(request: request, adminKey: snap.adminKey) - - struct Model: Encodable { - let id: String - let object = "model" - let owned_by: String - } - struct ModelsList: Encodable { - let object = "list" - let data: [Model] - } - - let models = snap.accounts.filter { $0.provider.isProxyRoutable }.flatMap { acct in - var accountModels: [Model] = [] - let alias = "\(acct.provider.routingSlug):\(acct.label.lowercased())" - if acct.defaultModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false { - accountModels.append(Model(id: alias, owned_by: acct.provider.displayName)) - } - for model in acct.availableModels ?? [] { - accountModels.append(Model( - id: "\(alias)/\(model.id)", - owned_by: model.ownedBy ?? acct.provider.displayName - )) - } - if accountModels.isEmpty { - accountModels.append(Model(id: alias, owned_by: acct.provider.displayName)) - } - return accountModels - } - let payload = try JSONEncoder().encode(ModelsList(data: models)) - return Response( - status: .ok, - headers: [.contentType: "application/json"], - body: .init(byteBuffer: ByteBuffer(bytes: payload)) - ) - } - - private static func handleOpenAIChat( - request: Request, - context: some RequestContext, - state: ProxyState, - forwarder: UpstreamForwarder - ) async throws -> Response { - try await handleOpenAIModelRequest( - request: request, - context: context, - state: state, - forwarder: forwarder, - upstreamPath: "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/chat/completions" - ) - } - - private static func handleOpenAIModelRequest( - request: Request, - context: some RequestContext, - state: ProxyState, - forwarder: UpstreamForwarder, - upstreamPath: String - ) async throws -> Response { - let startedAt = Date() - let snap = await state.snapshot() - let auth = try authorize(request: request, adminKey: snap.adminKey) - - let bodyBuf = try await request.body.collect(upTo: 16 * 1024 * 1024) - let bodyData = Data(buffer: bodyBuf) - let model = try extractModel(from: bodyData) - - let router = ModelRouter(accounts: snap.accounts) - guard let resolved = router.resolve( - model: model, - compatibility: .openAI, - preferredAccountID: auth.preferredAccountID - ) else { - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: "POST", - path: request.uri.path, - model: model, - statusCode: 502, - message: "no OpenAI-compatible account matches model '\(model)'" - ) - throw HTTPError(.badGateway, message: "no account matches model '\(model)'") - } - guard let apiKey = snap.apiKeys[resolved.account.id] else { - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: "POST", - path: request.uri.path, - model: model, - account: resolved.account, - upstreamModel: resolved.upstreamModel, - statusCode: 502, - message: "missing api key for account" - ) - throw HTTPError(.badGateway, message: "missing api key for account") - } - - let rewritten = rewriteModel(in: bodyData, to: resolved.upstreamModel) - let url = try buildURL(base: resolved.account.endpoint, path: upstreamPath, query: request.uri.query) - - let forwardHeaders: [(String, String)] = [ - ("Authorization", "Bearer \(apiKey)"), - ("Content-Type", request.headers[.contentType] ?? "application/json"), - ("Accept", request.headers[.accept] ?? "application/json"), - ] - - return try await proxyResponse( - forwarder: forwarder, - state: state, - account: resolved.account, - model: resolved.upstreamModel, - requestedModel: model, - method: "POST", - path: request.uri.path, - startedAt: startedAt, - url: url, - headers: forwardHeaders, - body: rewritten - ) - } - - private static func handleOpenAIPassthrough( - request: Request, - context: some RequestContext, - state: ProxyState, - forwarder: UpstreamForwarder - ) async throws -> Response { - let startedAt = Date() - let snap = await state.snapshot() - let auth = try authorize(request: request, adminKey: snap.adminKey) - let router = ModelRouter(accounts: snap.accounts) - - guard let resolved = router.resolveDefault(compatibility: .openAI, preferredAccountID: auth.preferredAccountID) else { - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: requestMethodName(request), - path: request.uri.path, - statusCode: 502, - message: "no OpenAI-compatible account available" - ) - throw HTTPError(.badGateway, message: "no OpenAI-compatible account available") - } - guard let apiKey = snap.apiKeys[resolved.account.id] else { - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: requestMethodName(request), - path: request.uri.path, - account: resolved.account, - upstreamModel: resolved.upstreamModel, - statusCode: 502, - message: "missing api key for account" - ) - throw HTTPError(.badGateway, message: "missing api key for account") - } - - let bodyBuf = try await request.body.collect(upTo: 16 * 1024 * 1024) - let bodyData = Data(buffer: bodyBuf) - let upstreamPath = stripV1Prefix(request.uri.path) - let url = try buildURL(base: resolved.account.endpoint, path: upstreamPath, query: request.uri.query) - var forwardHeaders: [(String, String)] = [ - ("Authorization", "Bearer \(apiKey)"), - ("Accept", request.headers[.accept] ?? "application/json"), - ] - if !bodyData.isEmpty { - forwardHeaders.append(("Content-Type", request.headers[.contentType] ?? "application/json")) - } - - return try await proxyResponse( - forwarder: forwarder, - state: state, - account: resolved.account, - model: resolved.upstreamModel, - requestedModel: nil, - method: requestMethodName(request), - path: request.uri.path, - startedAt: startedAt, - url: url, - headers: forwardHeaders, - body: bodyData.isEmpty ? nil : bodyData - ) - } - - private static func handleAnthropicMessages( - request: Request, - context: some RequestContext, - state: ProxyState, - forwarder: UpstreamForwarder, - upstreamPath: String = "/messages" - ) async throws -> Response { - let startedAt = Date() - let snap = await state.snapshot() - let auth = try authorize(request: request, adminKey: snap.adminKey) - - let bodyBuf = try await request.body.collect(upTo: 16 * 1024 * 1024) - let bodyData = Data(buffer: bodyBuf) - let model = try extractModel(from: bodyData) - - let router = ModelRouter(accounts: snap.accounts) - let resolved: ModelRouter.Resolution - if let preferredAccountID = auth.preferredAccountID { - guard let routed = router.resolve( - model: model, - compatibility: .anthropic, - preferredAccountID: preferredAccountID - ), routed.account.provider.isClaudeCodeCompatible else { - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: "POST", - path: request.uri.path, - model: model, - statusCode: 502, - message: "selected Claude Code account is unavailable" - ) - throw HTTPError(.badGateway, message: "selected Claude Code account is unavailable") - } - resolved = routed - } else { - guard let routed = router.resolve(model: model, compatibility: .anthropic) else { - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: "POST", - path: request.uri.path, - model: model, - statusCode: 502, - message: "no Anthropic account matches model '\(model)'" - ) - throw HTTPError(.badGateway, message: "no Anthropic account matches model '\(model)'") - } - resolved = routed - } - guard let apiKey = snap.apiKeys[resolved.account.id] else { - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: "POST", - path: request.uri.path, - model: model, - account: resolved.account, - upstreamModel: resolved.upstreamModel, - statusCode: 502, - message: "missing api key for account" - ) - throw HTTPError(.badGateway, message: "missing api key for account") - } - - let rewritten = rewriteModel(in: bodyData, to: resolved.upstreamModel) - let url = try buildURL(base: resolved.account.endpoint, path: upstreamPath, query: request.uri.query) - - let forwardHeaders: [(String, String)] = [ - ("x-api-key", apiKey), - ("anthropic-version", request.headers[HTTPField.Name("anthropic-version")!] ?? "2023-06-01"), - ("Content-Type", request.headers[.contentType] ?? "application/json"), - ("Accept", request.headers[.accept] ?? "application/json"), - ] - - return try await proxyResponse( - forwarder: forwarder, - state: state, - account: resolved.account, - model: resolved.upstreamModel, - requestedModel: model, - method: "POST", - path: request.uri.path, - startedAt: startedAt, - url: url, - headers: forwardHeaders, - body: rewritten - ) - } - - // MARK: helpers - - private struct AuthorizationContext { - let preferredAccountID: UUID? - } - - private static func authorize(request: Request, adminKey: String) throws -> AuthorizationContext { - guard !adminKey.isEmpty else { - throw HTTPError(.serviceUnavailable, message: "proxy admin key not configured") - } - let header = request.headers[.authorization] - ?? request.headers[HTTPField.Name("x-api-key")!] - guard let header else { - throw HTTPError(.unauthorized, message: "missing credentials") - } - let presented = header.hasPrefix("Bearer ") ? String(header.dropFirst(7)) : header - let parts = presented.split(separator: ":", maxSplits: 1).map(String.init) - guard constantTimeEquals(parts.first ?? "", adminKey) else { - throw HTTPError(.unauthorized, message: "invalid credentials") - } - let preferredAccountID = parts.count == 2 ? UUID(uuidString: parts[1]) : nil - return AuthorizationContext(preferredAccountID: preferredAccountID) - } - - private static func extractModel(from data: Data) throws -> String { - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let model = json["model"] as? String, !model.isEmpty else { - throw HTTPError(.badRequest, message: "missing 'model' field") - } - return model - } - - private static func rewriteModel(in data: Data, to upstreamModel: String) -> Data { - guard var json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return data - } - json["model"] = upstreamModel - return (try? JSONSerialization.data(withJSONObject: json)) ?? data - } - - private static func buildURL(base: String, path: String, query: String? = nil) throws -> URL { - let trimmedBase = base.trimmingCharacters(in: .whitespaces).trimmingSuffix("/") - let querySuffix = query.map { "?\($0)" } ?? "" - guard let url = URL(string: trimmedBase + path + querySuffix) else { - throw HTTPError(.badGateway, message: "invalid upstream URL") - } - return url - } - - private static func stripV1Prefix(_ path: String) -> String { - guard path.hasPrefix("/v1/") else { return path } - return "/" + path.dropFirst(4) - } - - private static func requestMethodName(_ request: Request) -> String { - String(describing: request.method).uppercased() - } - - private static func recordFailedRequest( - state: ProxyState, - startedAt: Date, - method: String, - path: String, - model: String? = nil, - account: Account? = nil, - upstreamModel: String? = nil, - statusCode: Int?, - message: String - ) async { - let latencyMS = max(0, Int(Date().timeIntervalSince(startedAt) * 1000)) - await state.recordRequestLog(ProxyRequestLog( - timestamp: startedAt, - method: method, - path: path, - accountID: account?.id, - accountLabel: account?.label, - provider: account?.provider, - model: model, - upstreamModel: upstreamModel, - statusCode: statusCode, - latencyMS: latencyMS, - errorMessage: message - )) - } - - private static func proxyResponse( - forwarder: UpstreamForwarder, - state: ProxyState, - account: Account, - model: String, - requestedModel: String?, - method: String, - path: String, - startedAt: Date, - url: URL, - headers: [(String, String)], - body: Data? - ) async throws -> Response { - let upstream: UpstreamForwarder.Forwarded - do { - upstream = try await forwarder.forward(method: method, url: url, headers: headers, body: body) - } catch { - await state.recordFailure(accountID: account.id) - await recordFailedRequest( - state: state, - startedAt: startedAt, - method: method, - path: path, - model: requestedModel, - account: account, - upstreamModel: model, - statusCode: 502, - message: "upstream connect failed: \(error.localizedDescription)" - ) - throw HTTPError(.badGateway, message: "upstream connect failed: \(error.localizedDescription)") - } - if upstream.status >= 500 { - await state.recordFailure(accountID: account.id) - } - - var responseHeaders = HTTPFields() - for (k, v) in upstream.headers { - if let name = HTTPField.Name(k) { - responseHeaders.append(HTTPField(name: name, value: v)) - } - } - let contentType = upstream.headers.first { $0.0.caseInsensitiveCompare("content-type") == .orderedSame }?.1 - let body = UsageTrackingStream( - base: upstream.body, - state: state, - accountID: account.id, - provider: account.provider, - model: model, - contentType: contentType, - requestLogContext: ProxyRequestLogContext( - startedAt: startedAt, - method: method, - path: path, - accountLabel: account.label, - requestedModel: requestedModel ?? "", - statusCode: upstream.status - ) - ) - let status = HTTPResponse.Status(code: upstream.status) - return Response( - status: status, - headers: responseHeaders, - body: .init(asyncSequence: body) - ) - } - - private static func constantTimeEquals(_ a: String, _ b: String) -> Bool { - let aBytes = Array(a.utf8) - let bBytes = Array(b.utf8) - guard aBytes.count == bBytes.count else { return false } - var diff: UInt8 = 0 - for i in 0.. String { - hasSuffix(suffix) ? String(dropLast(suffix.count)) : self - } -} diff --git a/CoderSwitch/Proxy/ProxySettings.swift b/CoderSwitch/Proxy/ProxySettings.swift deleted file mode 100644 index ce0d2bc..0000000 --- a/CoderSwitch/Proxy/ProxySettings.swift +++ /dev/null @@ -1,131 +0,0 @@ -import Foundation -import Observation -import GRDB - -@Observable -@MainActor -final class ProxySettings { - var port: Int { - didSet { save() } - } - var adminKey: String { - didSet { save() } - } - var autoStart: Bool { - didSet { save() } - } - - private let db: DatabaseQueue - - init() { - self.db = AppDatabase.shared.queue - - if let stored = Self.load(from: db) { - self.port = stored.port - self.adminKey = stored.adminKey - self.autoStart = stored.autoStart - } else if let migrated = Self.migrateFromJSON() { - self.port = migrated.port - self.adminKey = migrated.adminKey - self.autoStart = migrated.autoStart - save() - } else { - self.port = 8484 - self.adminKey = Self.generateKey() - self.autoStart = false - save() - } - } - - var baseURL: String { "http://127.0.0.1:\(port)" } - - var configExportItem: CoderSwitchConfigProxySettings { - CoderSwitchConfigProxySettings( - port: port, - adminKey: adminKey, - autoStart: autoStart - ) - } - - func applyConfigImport(_ imported: CoderSwitchConfigProxySettings) { - port = imported.port - adminKey = imported.adminKey - autoStart = imported.autoStart - } - - func resetToDefaults() { - port = 8484 - adminKey = Self.generateKey() - autoStart = false - } - - private static func generateKey() -> String { - var bytes = [UInt8](repeating: 0, count: 24) - _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - return "cs-" + Data(bytes).base64EncodedString() - .replacingOccurrences(of: "+", with: "-") - .replacingOccurrences(of: "/", with: "_") - .replacingOccurrences(of: "=", with: "") - } - - func regenerateKey() { - adminKey = Self.generateKey() - } - - private struct Row { - let port: Int - let adminKey: String - let autoStart: Bool - } - - private static func load(from db: DatabaseQueue) -> Row? { - try? db.read { db in - try GRDB.Row.fetchOne(db, sql: "SELECT port, admin_key, auto_start FROM proxy_settings WHERE id = 1") - .map { row in - Row( - port: row["port"], - adminKey: row["admin_key"], - autoStart: (row["auto_start"] as Int) != 0 - ) - } - } ?? nil - } - - private static func migrateFromJSON() -> Row? { - let dir = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("CoderSwitch", isDirectory: true) - let jsonURL = dir.appendingPathComponent("proxy.json") - guard let data = try? Data(contentsOf: jsonURL), - let stored = try? JSONDecoder().decode(LegacyStored.self, from: data) else { - return nil - } - try? FileManager.default.removeItem(at: jsonURL) - return Row(port: stored.port, adminKey: stored.adminKey, autoStart: stored.autoStart) - } - - private struct LegacyStored: Codable { - let port: Int - let adminKey: String - let autoStart: Bool - } - - private func save() { - let port = self.port - let adminKey = self.adminKey - let autoStart = self.autoStart - try? db.write { db in - try db.execute( - sql: """ - INSERT INTO proxy_settings (id, port, admin_key, auto_start) - VALUES (1, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - port = excluded.port, - admin_key = excluded.admin_key, - auto_start = excluded.auto_start - """, - arguments: [port, adminKey, autoStart ? 1 : 0] - ) - } - } -} diff --git a/CoderSwitch/Proxy/ProxyState.swift b/CoderSwitch/Proxy/ProxyState.swift deleted file mode 100644 index 0583b2f..0000000 --- a/CoderSwitch/Proxy/ProxyState.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation - -/// Sendable snapshot of state needed by the proxy server's request handlers. -/// Updated by the MainActor app whenever accounts/keys/settings change. -actor ProxyState { - private(set) var accounts: [Account] = [] - private(set) var apiKeys: [UUID: String] = [:] - private(set) var adminKey: String = "" - private var usageRecorder: TokenUsageRecorder? - private var requestLogRecorder: RequestLogRecorder? - private var failureRecorder: (@Sendable (UUID) -> Void)? - - func update(accounts: [Account], apiKeys: [UUID: String], adminKey: String) { - self.accounts = accounts - self.apiKeys = apiKeys - self.adminKey = adminKey - } - - func setUsageRecorder(_ usageRecorder: TokenUsageRecorder) { - self.usageRecorder = usageRecorder - } - - func setRequestLogRecorder(_ requestLogRecorder: RequestLogRecorder) { - self.requestLogRecorder = requestLogRecorder - } - - func setFailureRecorder(_ recorder: @escaping @Sendable (UUID) -> Void) { - self.failureRecorder = recorder - } - - func recordUsage(_ event: TokenUsageEvent) async { - await usageRecorder?.record(event) - } - - func recordRequestLog(_ log: ProxyRequestLog) async { - await requestLogRecorder?.record(log) - } - - func recordFailure(accountID: UUID) { - failureRecorder?(accountID) - } - - struct Snapshot: Sendable { - let accounts: [Account] - let apiKeys: [UUID: String] - let adminKey: String - } - - func snapshot() -> Snapshot { - Snapshot(accounts: accounts, apiKeys: apiKeys, adminKey: adminKey) - } -} diff --git a/CoderSwitch/Proxy/QuotaPoller.swift b/CoderSwitch/Proxy/QuotaPoller.swift deleted file mode 100644 index aa81f67..0000000 --- a/CoderSwitch/Proxy/QuotaPoller.swift +++ /dev/null @@ -1,616 +0,0 @@ -import Foundation -import Observation -import os - -private let quotaLog = Logger(subsystem: "com.coderswitch.proxy", category: "quota") -private let googleAntigravityUserAgent = "antigravity/2.0.1 darwin/arm64" - -@Observable -@MainActor -final class QuotaPoller { - private(set) var isPolling = false - private(set) var lastRunAt: Date? - - private weak var store: AccountStore? - private weak var oauthStore: OAuthStore? - private var timerTask: Task? - - /// How often to run the full poll cycle. - var interval: Duration = .seconds(60) - - func bind(store: AccountStore, oauthStore: OAuthStore? = nil) { - self.store = store - self.oauthStore = oauthStore - } - - func start() { - guard timerTask == nil else { return } - quotaLog.info("quota poller starting (interval=\(self.interval, privacy: .public))") - timerTask = Task { [weak self] in - while !Task.isCancelled { - await self?.pollOnce() - if Task.isCancelled { break } - try? await Task.sleep(for: self?.interval ?? .seconds(60)) - } - } - } - - func stop() { - timerTask?.cancel() - timerTask = nil - } - - func pollOnce() async { - guard let store else { return } - guard !isPolling else { return } - isPolling = true - defer { isPolling = false } - - var standardSnapshots: [(Account, String?, QuotaCheck)] = [] - var codexSnapshots: [(Account, String)] = [] - var antigravitySnapshots: [(Account, String)] = [] - - for account in store.accounts where account.isEnabled { - if account.provider == .codex { - guard let oauthStore else { - store.applyPollResult( - accountID: account.id, - limits: [], - error: "missing Codex OAuth store" - ) - continue - } - guard let oauth = oauthStore.codexAccount(matching: account) else { - store.applyPollResult( - accountID: account.id, - limits: [], - error: "missing Codex OAuth account" - ) - continue - } - do { - let token = try await oauthStore.refreshTokenIfNeeded(for: oauth) - codexSnapshots.append((account, token.accessToken)) - } catch { - store.applyPollResult( - accountID: account.id, - limits: [], - error: error.localizedDescription - ) - } - continue - } - - if account.provider == .googleAntigravity { - guard let oauthStore else { - store.applyPollResult( - accountID: account.id, - limits: [], - error: "missing Google Antigravity OAuth store" - ) - continue - } - guard let oauth = oauthStore.geminiAccount(matching: account) else { - store.applyPollResult( - accountID: account.id, - limits: [], - error: "missing Google Antigravity OAuth account" - ) - continue - } - do { - let token = try await oauthStore.refreshTokenIfNeeded(for: oauth) - antigravitySnapshots.append((account, token.accessToken)) - } catch { - store.applyPollResult( - accountID: account.id, - limits: [], - error: error.localizedDescription - ) - } - continue - } - - guard let check = account.provider.quotaCheck else { continue } - standardSnapshots.append((account, store.apiKey(for: account), check)) - } - - await withTaskGroup(of: Void.self) { group in - for (account, apiKey, check) in standardSnapshots { - group.addTask { [weak self] in - await self?.poll(account: account, apiKey: apiKey, check: check) - } - } - - for (account, accessToken) in codexSnapshots { - group.addTask { [weak self] in - await self?.pollCodex(account: account, accessToken: accessToken) - } - } - - for (account, accessToken) in antigravitySnapshots { - group.addTask { [weak self] in - await self?.pollGoogleAntigravity(account: account, accessToken: accessToken) - } - } - } - - lastRunAt = Date() - } - - private nonisolated func poll( - account: Account, - apiKey: String?, - check: QuotaCheck - ) async { - let result: Result<[UsageLimit], Error> - do { - guard let apiKey, !apiKey.isEmpty else { - throw QuotaCheckError.missingAPIKey - } - let url = try check.url(forEndpoint: account.endpoint) - var req = URLRequest(url: url) - req.httpMethod = "GET" - req.timeoutInterval = 15 - req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") - for (name, value) in check.headers { - req.setValue(value, forHTTPHeaderField: name) - } - let (data, response) = try await URLSession.shared.data(for: req) - let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - let limits = try check.parse(data, status) - result = .success(limits) - } catch { - result = .failure(error) - } - - await MainActor.run { [weak self] in - switch result { - case .success(let limits): - quotaLog.info("quota OK \(account.label, privacy: .public): \(limits.count, privacy: .public) limits") - self?.store?.applyPollResult( - accountID: account.id, - limits: limits, - error: nil - ) - case .failure(let error): - let msg = error.localizedDescription - quotaLog.error("quota FAIL \(account.label, privacy: .public): \(msg, privacy: .public)") - self?.store?.applyPollResult( - accountID: account.id, - limits: [], - error: msg - ) - } - } - } - - private nonisolated func pollCodex(account: Account, accessToken: String) async { - let result: Result<[UsageLimit], Error> - do { - let url = try CodexUsageResponse.usageURL(forEndpoint: account.endpoint) - var req = URLRequest(url: url) - req.httpMethod = "GET" - req.timeoutInterval = 15 - req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - req.setValue("application/json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: req) - let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - guard (200..<300).contains(status) else { - throw QuotaCheckError.httpStatus(status) - } - - let usage = try JSONDecoder().decode(CodexUsageResponse.self, from: data) - let limits = usage.usageLimits - guard !limits.isEmpty else { - throw QuotaCheckError.providerMessage("no Codex quota windows returned") - } - result = .success(limits) - } catch { - result = .failure(error) - } - - await MainActor.run { [weak self] in - switch result { - case .success(let limits): - quotaLog.info("codex quota OK \(account.label, privacy: .public): \(limits.count, privacy: .public) limits") - self?.store?.applyPollResult( - accountID: account.id, - limits: limits, - error: nil - ) - case .failure(let error): - let msg = error.localizedDescription - quotaLog.error("codex quota FAIL \(account.label, privacy: .public): \(msg, privacy: .public)") - self?.store?.applyPollResult( - accountID: account.id, - limits: [], - error: msg - ) - } - } - } - - private nonisolated func fetchGoogleAntigravityProjectId(accessToken: String) async -> String? { - let endpoints = [ - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - "https://daily-cloudcode-pa.googleapis.com/v1internal:loadCodeAssist" - ] - - let payload: [String: Any] = [ - "clientMetadata": [ - "ideType": "ANTIGRAVITY", - "platform": "PLATFORM_UNSPECIFIED", - "pluginType": "GEMINI" - ] - ] - let body = try? JSONSerialization.data(withJSONObject: payload) - - for endpoint in endpoints { - guard let url = URL(string: endpoint) else { continue } - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.timeoutInterval = 15 - req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue(googleAntigravityUserAgent, forHTTPHeaderField: "User-Agent") - req.httpBody = body - - do { - let (data, response) = try await URLSession.shared.data(for: req) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { continue } - if let decoded = try? JSONDecoder().decode(AntigravityLoadCodeAssistResponse.self, from: data), - let projectId = decoded.cloudaicompanionProject { - return projectId - } - } catch { - continue - } - } - return nil - } - - private nonisolated func pollGoogleAntigravity(account: Account, accessToken: String) async { - let result: Result<[UsageLimit], Error> - do { - let projectId = await fetchGoogleAntigravityProjectId(accessToken: accessToken) ?? "" - - let endpoints = [ - "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:fetchAvailableModels", - "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", - "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels" - ] - - var lastError: Error = QuotaCheckError.providerMessage("no endpoints attempted") - var succeeded = false - var limits: [UsageLimit] = [] - - var payloads: [[String: Any]] = [[:]] - if !projectId.isEmpty { - payloads.append(["project": projectId]) - } - - for endpoint in endpoints { - guard let url = URL(string: endpoint) else { continue } - for payload in payloads { - var req = URLRequest(url: url) - req.httpMethod = "POST" - req.timeoutInterval = 15 - req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.setValue(googleAntigravityUserAgent, forHTTPHeaderField: "User-Agent") - req.httpBody = try JSONSerialization.data(withJSONObject: payload) - - do { - let (data, response) = try await URLSession.shared.data(for: req) - let status = (response as? HTTPURLResponse)?.statusCode ?? 0 - if status == 401 || status == 403 { - lastError = QuotaCheckError.providerMessage( - "Google Antigravity authorization was rejected (HTTP \(status)); reconnect this account" - ) - continue - } else if status >= 500 { - lastError = QuotaCheckError.httpStatus(status) - continue - } else if status < 200 || status >= 300 && status != 429 { - lastError = QuotaCheckError.httpStatus(status) - continue - } - - let decoded = try JSONDecoder().decode(AntigravityFetchAvailableModelsResponse.self, from: data) - limits = decoded.usageLimits - succeeded = true - break - } catch { - lastError = error - continue - } - } - if succeeded { break } - } - - if succeeded { - if limits.isEmpty { - let existingUserFacingLimits = account.usageLimits.filter { !$0.isGoogleAntigravityInternalModel } - if existingUserFacingLimits.isEmpty { - result = .failure(QuotaCheckError.providerMessage("no user-facing Google Antigravity model limits returned")) - } else { - result = .success(existingUserFacingLimits) - } - } else { - result = .success(limits) - } - } else { - result = .failure(lastError) - } - } catch { - result = .failure(error) - } - - await MainActor.run { [weak self] in - switch result { - case .success(let limits): - quotaLog.info("google antigravity quota OK \(account.label, privacy: .public): \(limits.count, privacy: .public) limits") - self?.store?.applyPollResult( - accountID: account.id, - limits: limits, - error: nil - ) - case .failure(let error): - let msg = error.localizedDescription - quotaLog.error("google antigravity quota FAIL \(account.label, privacy: .public): \(msg, privacy: .public)") - self?.store?.applyPollResult( - accountID: account.id, - limits: [], - error: msg - ) - } - } - } -} - -struct CodexUsageResponse: Decodable { - private let planType: String? - private let rateLimit: CodexRateLimit? - private let credits: CodexCredits? - - enum CodingKeys: String, CodingKey { - case planType = "plan_type" - case rateLimit = "rate_limit" - case credits - } - - static func usageURL(forEndpoint endpoint: String) throws -> URL { - let trimmed = endpoint.trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "/")) - guard let url = URL(string: "\(trimmed)/wham/usage") else { - throw QuotaCheckError.providerMessage("invalid Codex quota endpoint") - } - return url - } - - var usageLimits: [UsageLimit] { - var limits = [ - rateLimit?.primaryWindow?.usageLimit(name: "5-hour limit"), - rateLimit?.secondaryWindow?.usageLimit(name: "Weekly limit") - ].compactMap { $0 } - - if let credits = credits?.usageLimit { - limits.append(credits) - } - - return limits - } -} - -private struct CodexRateLimit: Decodable { - let primaryWindow: CodexRateLimitWindow? - let secondaryWindow: CodexRateLimitWindow? - - enum CodingKeys: String, CodingKey { - case primaryWindow = "primary_window" - case secondaryWindow = "secondary_window" - } -} - -private struct CodexRateLimitWindow: Decodable { - let usedPercent: Double? - let limitWindowSeconds: Double? - let resetAt: Double? - - enum CodingKeys: String, CodingKey { - case usedPercent = "used_percent" - case limitWindowSeconds = "limit_window_seconds" - case resetAt = "reset_at" - } - - var resetDate: Date? { - resetAt.map { Date(timeIntervalSince1970: $0) } - } - - func usageLimit(name: String) -> UsageLimit? { - guard let usedPercent else { return nil } - return UsageLimit( - name: name, - used: usedPercent, - limit: 100, - unit: "%", - resetAt: resetDate - ) - } -} - -private struct CodexCredits: Decodable { - let hasCredits: Bool? - let unlimited: Bool? - let balance: String? - - enum CodingKeys: String, CodingKey { - case hasCredits = "has_credits" - case unlimited - case balance - } - - var usageLimit: UsageLimit? { - guard hasCredits == true || unlimited == true else { return nil } - if unlimited == true { - return UsageLimit( - name: "Credits", - used: nil, - limit: nil, - unit: nil, - isPayAsYouGo: true - ) - } - guard let balanceValue = balance.flatMap(Double.init) else { return nil } - return UsageLimit( - name: "Credits", - used: balanceValue, - limit: nil, - valuePrefix: "$", - unit: nil, - isPayAsYouGo: true - ) - } -} - -private struct AntigravityLoadCodeAssistResponse: Decodable { - let cloudaicompanionProject: String? -} - -struct AntigravityFetchAvailableModelsResponse: Decodable { - let models: [String: AntigravityModelQuotaInfo]? - let aiCredits: AntigravityCreditsInfo? - - enum CodingKeys: String, CodingKey { - case models - case aiCredits = "ai_credits" - } - - var usageLimits: [UsageLimit] { - var limits: [UsageLimit] = [] - if let models = models { - for (modelName, info) in models { - guard !info.isInternal, !info.isInternalModel(modelID: modelName) else { continue } - guard let percentageVal = info.remainingFraction else { continue } - let remainingPercent: Double - if percentageVal <= 1.0 && percentageVal >= 0.0 { - remainingPercent = percentageVal * 100.0 - } else { - remainingPercent = percentageVal - } - - let resetDate = info.resetTimeValue.flatMap { Self.date(from: $0) } - let displayName = info.userFacingDisplayName(modelID: modelName) - - limits.append(UsageLimit( - name: displayName, - storageID: modelName, - used: min(100, max(0, remainingPercent)), - limit: 100, - unit: "%", - resetAt: resetDate, - valueKind: "googleAntigravityRemainingPercent" - )) - } - } - - if let credits = aiCredits { - let resetDate = Self.date(from: credits.expiryDate) - limits.append(UsageLimit( - name: "AI Credits", - used: credits.credits, - limit: nil, - valuePrefix: nil, - unit: nil, - resetAt: resetDate, - isPayAsYouGo: true - )) - } - - return limits.sorted { $0.name < $1.name } - } - - private static func date(from value: String) -> Date? { - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return fractional.date(from: value) ?? ISO8601DateFormatter().date(from: value) - } -} - -struct AntigravityModelQuotaInfo: Decodable { - let quotaInfo: AntigravityQuotaInfo? - let percentage: Double? - let resetTime: String? - let displayName: String? - let model: String? - let isInternal: Bool - - enum CodingKeys: String, CodingKey { - case quotaInfo - case percentage - case resetTime - case displayName - case display_name - case model - case isInternal - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - quotaInfo = try container.decodeIfPresent(AntigravityQuotaInfo.self, forKey: .quotaInfo) - percentage = try container.decodeIfPresent(Double.self, forKey: .percentage) - resetTime = try container.decodeIfPresent(String.self, forKey: .resetTime) - displayName = try container.decodeIfPresent(String.self, forKey: .displayName) - ?? container.decodeIfPresent(String.self, forKey: .display_name) - model = try container.decodeIfPresent(String.self, forKey: .model) - isInternal = try container.decodeIfPresent(Bool.self, forKey: .isInternal) ?? false - } - - var remainingFraction: Double? { - quotaInfo?.remainingFraction ?? percentage - } - - var resetTimeValue: String? { - quotaInfo?.resetTime ?? resetTime - } - - func userFacingDisplayName(modelID: String) -> String { - [displayName, model, modelID] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty } - .first { !Self.isInternalIdentifier($0) } - ?? modelID - } - - func isInternalModel(modelID: String) -> Bool { - Self.isInternalIdentifier(displayName, missingIsInternal: false) - || Self.isInternalIdentifier(model, missingIsInternal: false) - || Self.isInternalIdentifier(modelID, missingIsInternal: false) - } - - private static func isInternalIdentifier(_ value: String?, missingIsInternal: Bool = true) -> Bool { - guard let value else { return missingIsInternal } - let cleaned = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !cleaned.isEmpty else { - return missingIsInternal - } - return cleaned.hasPrefix("model_placeholder") - || cleaned.hasPrefix("chat_") - || cleaned.hasPrefix("tab_") - } -} - -struct AntigravityQuotaInfo: Decodable { - let remainingFraction: Double? - let resetTime: String? -} - -struct AntigravityCreditsInfo: Decodable { - let credits: Double - let expiryDate: String -} - -private extension String { - var nonEmpty: String? { isEmpty ? nil : self } -} diff --git a/CoderSwitch/Proxy/RequestLogRecorder.swift b/CoderSwitch/Proxy/RequestLogRecorder.swift deleted file mode 100644 index b903c43..0000000 --- a/CoderSwitch/Proxy/RequestLogRecorder.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -actor RequestLogRecorder { - private weak var store: RequestLogStore? - - init(store: RequestLogStore) { - self.store = store - } - - func record(_ log: ProxyRequestLog) async { - await MainActor.run { [weak store] in - store?.record(log) - } - } -} diff --git a/CoderSwitch/Proxy/TokenUsageRecorder.swift b/CoderSwitch/Proxy/TokenUsageRecorder.swift deleted file mode 100644 index 44a7abd..0000000 --- a/CoderSwitch/Proxy/TokenUsageRecorder.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -actor TokenUsageRecorder { - private weak var store: AccountStore? - - init(store: AccountStore) { - self.store = store - } - - func record(_ event: TokenUsageEvent) async { - await MainActor.run { [weak store] in - store?.recordTokenUsage(event) - } - } -} diff --git a/CoderSwitch/Proxy/UpstreamForwarder.swift b/CoderSwitch/Proxy/UpstreamForwarder.swift deleted file mode 100644 index 031ca1c..0000000 --- a/CoderSwitch/Proxy/UpstreamForwarder.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import NIOCore - -/// Forwards a request to an upstream provider and returns a streaming body. -struct UpstreamForwarder { - struct Forwarded { - let status: Int - let headers: [(String, String)] - let body: ByteStream - } - - /// AsyncSequence of ByteBuffers wrapping URLSession.AsyncBytes. - struct ByteStream: AsyncSequence, Sendable { - typealias Element = ByteBuffer - let bytes: URLSession.AsyncBytes - - struct AsyncIterator: AsyncIteratorProtocol { - var inner: URLSession.AsyncBytes.AsyncIterator - - mutating func next() async throws -> ByteBuffer? { - var chunk: [UInt8] = [] - chunk.reserveCapacity(4096) - while chunk.count < 4096 { - guard let byte = try await inner.next() else { break } - chunk.append(byte) - } - if chunk.isEmpty { return nil } - return ByteBuffer(bytes: chunk) - } - } - - func makeAsyncIterator() -> AsyncIterator { - AsyncIterator(inner: bytes.makeAsyncIterator()) - } - } - - let session: URLSession - - init(session: URLSession = .shared) { - self.session = session - } - - func forward( - method: String, - url: URL, - headers: [(String, String)], - body: Data? - ) async throws -> Forwarded { - var request = URLRequest(url: url) - request.httpMethod = method - for (name, value) in headers { - request.setValue(value, forHTTPHeaderField: name) - } - request.httpBody = body - request.timeoutInterval = 600 - - let (bytes, response) = try await session.bytes(for: request) - guard let http = response as? HTTPURLResponse else { - throw ProxyError.upstreamFailed("non-HTTP response") - } - - var outHeaders: [(String, String)] = [] - for (key, value) in http.allHeaderFields { - guard let k = key as? String, let v = value as? String else { continue } - let lower = k.lowercased() - if lower == "transfer-encoding" || lower == "content-encoding" || lower == "connection" { - continue - } - outHeaders.append((k, v)) - } - - return Forwarded( - status: http.statusCode, - headers: outHeaders, - body: ByteStream(bytes: bytes) - ) - } -} - -enum ProxyError: Error, CustomStringConvertible { - case unauthorized - case badRequest(String) - case noAccount(String) - case upstreamFailed(String) - - var description: String { - switch self { - case .unauthorized: "unauthorized" - case .badRequest(let m): "bad request: \(m)" - case .noAccount(let m): "no account: \(m)" - case .upstreamFailed(let m): "upstream failed: \(m)" - } - } -} diff --git a/CoderSwitch/Proxy/UsageTrackingStream.swift b/CoderSwitch/Proxy/UsageTrackingStream.swift deleted file mode 100644 index 382bc14..0000000 --- a/CoderSwitch/Proxy/UsageTrackingStream.swift +++ /dev/null @@ -1,155 +0,0 @@ -import Foundation -import NIOCore - -struct UsageTrackingStream: AsyncSequence, Sendable { - typealias Element = ByteBuffer - - let base: UpstreamForwarder.ByteStream - let state: ProxyState - let accountID: UUID - let provider: Provider - let model: String - let contentType: String? - let requestLogContext: ProxyRequestLogContext? - - struct AsyncIterator: AsyncIteratorProtocol { - var base: UpstreamForwarder.ByteStream.AsyncIterator - let state: ProxyState - let accountID: UUID - let provider: Provider - let model: String - var collector: UsageResponseCollector - let requestLogContext: ProxyRequestLogContext? - var didRecord = false - - mutating func next() async throws -> ByteBuffer? { - do { - guard let chunk = try await base.next() else { - await recordIfNeeded() - return nil - } - collector.consume(chunk) - return chunk - } catch { - await recordIfNeeded(errorMessage: error.localizedDescription) - throw error - } - } - - private mutating func recordIfNeeded(errorMessage: String? = nil) async { - guard !didRecord else { return } - didRecord = true - var usage = collector.finish() ?? TokenUsageDelta() - if !usage.isEmpty { - if usage.requests == 0 { - usage.requests = 1 - } - await state.recordUsage(TokenUsageEvent( - accountID: accountID, - provider: provider, - model: model, - occurredAt: Date(), - usage: usage - )) - } - - if let requestLogContext { - let latencyMS = Swift.max(0, Int(Date().timeIntervalSince(requestLogContext.startedAt) * 1000)) - await state.recordRequestLog(ProxyRequestLog( - timestamp: requestLogContext.startedAt, - method: requestLogContext.method, - path: requestLogContext.path, - accountID: accountID, - accountLabel: requestLogContext.accountLabel, - provider: provider, - model: requestLogContext.requestedModel, - upstreamModel: model, - statusCode: requestLogContext.statusCode, - latencyMS: latencyMS, - usage: usage, - errorMessage: errorMessage - )) - } - } - } - - func makeAsyncIterator() -> AsyncIterator { - AsyncIterator( - base: base.makeAsyncIterator(), - state: state, - accountID: accountID, - provider: provider, - model: model, - collector: UsageResponseCollector(contentType: contentType), - requestLogContext: requestLogContext - ) - } -} - -struct ProxyRequestLogContext: Sendable { - let startedAt: Date - let method: String - let path: String - let accountLabel: String - let requestedModel: String - let statusCode: Int -} - -struct UsageResponseCollector: Sendable { - private let isEventStream: Bool - private var sseBuffer = "" - private var jsonBuffer = Data() - private var bestStreamingUsage = TokenUsageDelta() - private let maxJSONBytes = 16 * 1024 * 1024 - - init(contentType: String?) { - isEventStream = contentType?.lowercased().contains("text/event-stream") == true - } - - mutating func consume(_ buffer: ByteBuffer) { - let data = Data(buffer: buffer) - if isEventStream { - sseBuffer += String(decoding: data, as: UTF8.self) - parseCompleteSSEEvents() - } else if jsonBuffer.count + data.count <= maxJSONBytes { - jsonBuffer.append(data) - } - } - - mutating func finish() -> TokenUsageDelta? { - if isEventStream { - if !sseBuffer.isEmpty { - parseSSEEvent(sseBuffer) - sseBuffer.removeAll(keepingCapacity: false) - } - return bestStreamingUsage.isEmpty ? nil : bestStreamingUsage - } - return TokenUsageParser.parseJSONResponse(jsonBuffer) - } - - private mutating func parseCompleteSSEEvents() { - while let range = nextSSEDelimiterRange() { - let event = String(sseBuffer[.. Range? { - let lf = sseBuffer.range(of: "\n\n") - let crlf = sseBuffer.range(of: "\r\n\r\n") - switch (lf, crlf) { - case (.some(let a), .some(let b)): - return a.lowerBound < b.lowerBound ? a : b - case (.some(let range), nil), (nil, .some(let range)): - return range - case (nil, nil): - return nil - } - } -} diff --git a/CoderSwitch/Stores/AccountStore.swift b/CoderSwitch/Stores/AccountStore.swift deleted file mode 100644 index 68b6fc6..0000000 --- a/CoderSwitch/Stores/AccountStore.swift +++ /dev/null @@ -1,376 +0,0 @@ -import Foundation -import Observation -import GRDB - -@Observable -@MainActor -final class AccountStore { - private(set) var accounts: [Account] = [] - - private let db: DatabaseQueue - - init() { - self.db = AppDatabase.shared.queue - load() - migrateFromJSONIfNeeded() - } - - func add(_ account: Account, apiKey: String) throws { - try persist(account, apiKey: apiKey) - accounts.append(account) - } - - func syncCodexAccounts(from oauthAccounts: [OAuthAccount]) { - for oauth in oauthAccounts { - let alreadyExists = accounts.contains { account in - account.provider == .codex - && ( - (oauth.externalID != nil && account.externalID == oauth.externalID) - || (oauth.email != nil && account.label == oauth.email) - ) - } - guard !alreadyExists else { continue } - - let account = Account( - id: oauth.id, - label: oauth.label, - provider: .codex, - externalID: oauth.externalID, - createdAt: oauth.createdAt - ) - do { - try persist(account, apiKey: nil) - accounts.append(account) - } catch { - print("AccountStore.syncCodexAccounts failed: \(error)") - } - } - } - - func syncGoogleAntigravityAccounts(from oauthAccounts: [OAuthAccount]) { - for oauth in oauthAccounts { - let alreadyExists = accounts.contains { account in - account.provider == .googleAntigravity - && ( - (oauth.externalID != nil && account.externalID == oauth.externalID) - || (oauth.email != nil && account.label == oauth.email) - ) - } - guard !alreadyExists else { continue } - - let account = Account( - id: oauth.id, - label: oauth.label, - provider: .googleAntigravity, - externalID: oauth.externalID, - createdAt: oauth.createdAt - ) - do { - try persist(account, apiKey: nil) - accounts.append(account) - } catch { - print("AccountStore.syncGoogleAntigravityAccounts failed: \(error)") - } - } - } - - func rename(accountID: UUID, label: String) { - let cleaned = label.trimmingCharacters(in: .whitespacesAndNewlines) - guard !cleaned.isEmpty, - let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].label = cleaned - persistMetadata(accounts[idx]) - } - - func delete(_ account: Account) { - let id = account.id.uuidString - try? db.write { db in - try db.execute(sql: "DELETE FROM accounts WHERE id = ?", arguments: [id]) - } - accounts.removeAll { $0.id == account.id } - } - - func apiKey(for account: Account) -> String? { - let id = account.id.uuidString - let blob: Data? = try? db.read { db in - try GRDB.Row.fetchOne(db, sql: "SELECT api_key_encrypted FROM accounts WHERE id = ?", arguments: [id]) - .flatMap { $0["api_key_encrypted"] as Data? } - } ?? nil - guard let blob else { return nil } - return try? SecretBox.open(blob) - } - - func configExportItems() -> [CoderSwitchConfigAccount] { - accounts.map { account in - CoderSwitchConfigAccount(account: account, apiKey: apiKey(for: account)) - } - } - - func replaceAll(with items: [CoderSwitchConfigAccount]) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let rows = try items.map { item in - let json = String(data: try encoder.encode(item.account), encoding: .utf8) ?? "{}" - let blob = try item.apiKey.map { try SecretBox.seal($0) } - return ( - id: item.account.id.uuidString, - json: json, - apiKey: blob, - createdAt: Int64(item.account.createdAt.timeIntervalSince1970) - ) - } - - try db.write { db in - try db.execute(sql: "DELETE FROM accounts") - for row in rows { - try db.execute( - sql: """ - INSERT INTO accounts (id, json, api_key_encrypted, created_at) - VALUES (?, ?, ?, ?) - """, - arguments: [row.id, row.json, row.apiKey, row.createdAt] - ) - } - } - accounts = items.map(\.account) - } - - func applyPollResult( - accountID: UUID, - limits: [UsageLimit], - error: String?, - at date: Date = Date() - ) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - if error == nil { - accounts[idx].usageLimits = limits - if let visibleIDs = accounts[idx].menuBarUsageLimitIDs { - let availableIDs = Set(limits.map(\.id)) - accounts[idx].menuBarUsageLimitIDs = visibleIDs.intersection(availableIDs) - } - accounts[idx].lastCheckError = nil - } else { - accounts[idx].lastCheckError = error - } - accounts[idx].lastCheckedAt = date - persistMetadata(accounts[idx]) - } - - func updateModels(accountID: UUID, models: [ProviderModel]) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].availableModels = models - if accounts[idx].defaultModel == nil { - accounts[idx].defaultModel = models.first?.id - } - persistMetadata(accounts[idx]) - } - - func setDefaultModel(accountID: UUID, modelID: String?) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].defaultModel = modelID?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - persistMetadata(accounts[idx]) - } - - func setEnabled(accountID: UUID, isEnabled: Bool) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].isEnabled = isEnabled - persistMetadata(accounts[idx]) - } - - func setMenuBarUsageLimitVisible(accountID: UUID, limitID: String, isVisible: Bool) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - var ids = accounts[idx].menuBarUsageLimitIDs - ?? Set(accounts[idx].menuBarUsageLimits.map(\.id)) - if isVisible { - ids.insert(limitID) - } else { - ids.remove(limitID) - } - accounts[idx].menuBarUsageLimitIDs = ids - persistMetadata(accounts[idx]) - } - - func setMenuBarUsageLimitIDs(accountID: UUID, limitIDs: Set?) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].menuBarUsageLimitIDs = limitIDs - persistMetadata(accounts[idx]) - } - - func setCircuitBreaker(accountID: UUID, config: CircuitBreakerConfig) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].circuitBreaker = config - if !config.enabled { - accounts[idx].clearFailures() - } - persistMetadata(accounts[idx]) - } - - func recordFailure(accountID: UUID, at date: Date = Date()) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].recordFailure(at: date) - persistMetadata(accounts[idx]) - } - - func clearFailures(accountID: UUID) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].clearFailures() - persistMetadata(accounts[idx]) - } - - func recordTokenUsage(_ event: TokenUsageEvent) { - guard !event.usage.isEmpty, - let idx = accounts.firstIndex(where: { $0.id == event.accountID }) else { - return - } - var usage = event.usage - if usage.requests == 0 { - usage.requests = 1 - } - accounts[idx].recordTokenUsage(TokenUsageEvent( - accountID: event.accountID, - provider: event.provider, - model: event.model, - occurredAt: event.occurredAt, - usage: usage - )) - persistMetadata(accounts[idx]) - } - - func tokenUsageTotal(period: TokenUsagePeriod) -> TokenUsageDelta { - accounts.reduce(into: TokenUsageDelta()) { total, account in - total.add(account.tokenUsageTotal(period: period)) - } - } - - func tokenUsageTotalsByProvider(period: TokenUsagePeriod) -> [TokenUsageGroupTotal] { - var totals: [Provider: TokenUsageDelta] = [:] - for account in accounts { - totals[account.provider, default: TokenUsageDelta()].add(account.tokenUsageTotal(period: period)) - } - return totals - .filter { !$0.value.isEmpty } - .sorted { $0.key.displayName < $1.key.displayName } - .map { - TokenUsageGroupTotal( - id: $0.key.rawValue, - label: $0.key.displayName, - totals: $0.value - ) - } - } - - func tokenUsageTotalsByModel(period: TokenUsagePeriod) -> [TokenUsageGroupTotal] { - var totals: [String: TokenUsageDelta] = [:] - for account in accounts { - for item in account.tokenUsageTotalsByModel(period: period) { - let key = "\(account.provider.routingSlug)/\(item.label)" - totals[key, default: TokenUsageDelta()].add(item.totals) - } - } - return totals - .filter { !$0.value.isEmpty } - .sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending } - .map { TokenUsageGroupTotal(id: $0.key, label: $0.key, totals: $0.value) } - } - - var accountsByProvider: [(Provider, [Account])] { - Dictionary(grouping: accounts, by: \.provider) - .sorted { $0.key.displayName < $1.key.displayName } - .map { ($0.key, $0.value.sorted { $0.label < $1.label }) } - } - - func tokenUsageDailySeries(period: TokenUsagePeriod, now: Date = Date()) -> [(day: String, totals: TokenUsageDelta)] { - var byDay: [String: TokenUsageDelta] = [:] - for account in accounts { - for bucket in account.tokenUsageBuckets where period.contains(dayKey: bucket.day, now: now) { - byDay[bucket.day, default: TokenUsageDelta()].add(bucket.totals) - } - } - return byDay - .sorted { $0.key < $1.key } - .map { (day: $0.key, totals: $0.value) } - } - - func tokenUsageByAccount(period: TokenUsagePeriod) -> [(account: Account, totals: TokenUsageDelta)] { - accounts - .map { ($0, $0.tokenUsageTotal(period: period)) } - .filter { !$0.1.isEmpty } - .sorted { $0.1.totalTokens > $1.1.totalTokens } - } - - private func load() { - let rows: [(id: String, json: Data, createdAt: Int64)] = (try? db.read { db in - try GRDB.Row.fetchAll(db, sql: "SELECT id, json, created_at FROM accounts ORDER BY created_at ASC") - .map { row in - ( - id: row["id"] as String, - json: Data((row["json"] as String).utf8), - createdAt: row["created_at"] as Int64 - ) - } - }) ?? [] - let decoder = JSONDecoder() - accounts = rows.compactMap { try? decoder.decode(Account.self, from: $0.json) } - } - - private func persist(_ account: Account, apiKey: String?) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let json = String(data: try encoder.encode(account), encoding: .utf8) ?? "{}" - let blob = try apiKey.map { try SecretBox.seal($0) } - let id = account.id.uuidString - let createdAt = Int64(Date().timeIntervalSince1970) - try db.write { db in - try db.execute( - sql: """ - INSERT INTO accounts (id, json, api_key_encrypted, created_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - json = excluded.json, - api_key_encrypted = excluded.api_key_encrypted - """, - arguments: [id, json, blob, createdAt] - ) - } - } - - private func persistMetadata(_ account: Account) { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(account), - let json = String(data: data, encoding: .utf8) else { return } - let id = account.id.uuidString - try? db.write { db in - try db.execute( - sql: "UPDATE accounts SET json = ? WHERE id = ?", - arguments: [json, id] - ) - } - } - - private func migrateFromJSONIfNeeded() { - guard accounts.isEmpty else { return } - let dir = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("CoderSwitch", isDirectory: true) - let jsonURL = dir.appendingPathComponent("accounts.json") - guard let data = try? Data(contentsOf: jsonURL), - let decoded = try? JSONDecoder().decode([Account].self, from: data) else { - return - } - for account in decoded { - let key = (try? KeychainStore.getSecret(account: account.id.uuidString)) ?? "" - do { - try persist(account, apiKey: key) - accounts.append(account) - } catch { - continue - } - } - try? FileManager.default.removeItem(at: jsonURL) - } -} - -private extension String { - var nonEmpty: String? { isEmpty ? nil : self } -} diff --git a/CoderSwitch/Stores/ClaudeCodeConfigSwitcher.swift b/CoderSwitch/Stores/ClaudeCodeConfigSwitcher.swift deleted file mode 100644 index 5f9a869..0000000 --- a/CoderSwitch/Stores/ClaudeCodeConfigSwitcher.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Foundation - -struct ClaudeCodeConfigSwitcher { - let settingsURL: URL - let fileManager: FileManager - - init( - settingsURL: URL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".claude", isDirectory: true) - .appendingPathComponent("settings.json"), - fileManager: FileManager = .default - ) { - self.settingsURL = settingsURL - self.fileManager = fileManager - } - - func switchToCoderSwitch( - account: Account, - proxyBaseURL: String, - adminKey: String - ) throws { - let token = "\(adminKey):\(account.id.uuidString)" - var env: [String: String] = [ - "ANTHROPIC_BASE_URL": proxyBaseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")), - "ANTHROPIC_AUTH_TOKEN": token, - "API_TIMEOUT_MS": "600000", - ] - if let defaultModel = account.claudeCodeDefaultModel { - env["ANTHROPIC_MODEL"] = defaultModel - env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = defaultModel - env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = defaultModel - env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = defaultModel - } - try updateSettings { settings in - var currentEnv = settings["env"] as? [String: Any] ?? [:] - for key in Self.managedEnvKeys { - currentEnv.removeValue(forKey: key) - } - for (key, value) in env { - currentEnv[key] = value - } - settings["env"] = currentEnv - } - } - - func restoreOfficialClaude() throws { - try updateSettings { settings in - guard var env = settings["env"] as? [String: Any] else { return } - for key in Self.managedEnvKeys { - env.removeValue(forKey: key) - } - if env.isEmpty { - settings.removeValue(forKey: "env") - } else { - settings["env"] = env - } - } - } - - private func updateSettings(_ update: (inout [String: Any]) -> Void) throws { - var settings = try readSettings() - update(&settings) - try writeSettings(settings) - } - - private func readSettings() throws -> [String: Any] { - guard fileManager.fileExists(atPath: settingsURL.path) else { return [:] } - let data = try Data(contentsOf: settingsURL) - guard !data.isEmpty else { return [:] } - let json = try JSONSerialization.jsonObject(with: data) - return json as? [String: Any] ?? [:] - } - - private func writeSettings(_ settings: [String: Any]) throws { - let dir = settingsURL.deletingLastPathComponent() - try fileManager.createDirectory(at: dir, withIntermediateDirectories: true) - - let data = try JSONSerialization.data( - withJSONObject: settings, - options: [.prettyPrinted, .sortedKeys] - ) - let finalData = data + Data("\n".utf8) - let tmpURL = dir.appendingPathComponent(".\(settingsURL.lastPathComponent).tmp.\(UUID().uuidString)") - try finalData.write(to: tmpURL, options: .atomic) - _ = try? fileManager.removeItem(at: settingsURL.appendingPathExtension("coderswitch.bak")) - if fileManager.fileExists(atPath: settingsURL.path) { - try? fileManager.copyItem( - at: settingsURL, - to: settingsURL.appendingPathExtension("coderswitch.bak") - ) - } - _ = try fileManager.replaceItemAt(settingsURL, withItemAt: tmpURL) - } - - private static let managedEnvKeys: Set = [ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_AUTH_TOKEN", - "ANTHROPIC_API_KEY", - "ANTHROPIC_MODEL", - "ANTHROPIC_DEFAULT_HAIKU_MODEL", - "ANTHROPIC_DEFAULT_SONNET_MODEL", - "ANTHROPIC_DEFAULT_OPUS_MODEL", - "API_TIMEOUT_MS", - ] -} - -extension Account { - var claudeCodeDefaultModel: String? { - if let defaultModel = defaultModel?.trimmingCharacters(in: .whitespacesAndNewlines), - !defaultModel.isEmpty { - return defaultModel - } - return availableModels?.first?.id.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty - } -} - -private extension String { - var nonEmpty: String? { - isEmpty ? nil : self - } -} diff --git a/CoderSwitch/Stores/ConfigBackupStore.swift b/CoderSwitch/Stores/ConfigBackupStore.swift deleted file mode 100644 index c0fb9b7..0000000 --- a/CoderSwitch/Stores/ConfigBackupStore.swift +++ /dev/null @@ -1,109 +0,0 @@ -import Foundation - -struct CoderSwitchConfigArchive: Codable { - let format: String - let version: Int - let exportedAt: Date - let proxySettings: CoderSwitchConfigProxySettings - let accounts: [CoderSwitchConfigAccount] - let oauthAccounts: [OAuthAccount] - - init( - exportedAt: Date = Date(), - proxySettings: CoderSwitchConfigProxySettings, - accounts: [CoderSwitchConfigAccount], - oauthAccounts: [OAuthAccount] - ) { - self.format = "dev.forbes.CoderSwitch.config" - self.version = 1 - self.exportedAt = exportedAt - self.proxySettings = proxySettings - self.accounts = accounts - self.oauthAccounts = oauthAccounts - } -} - -struct CoderSwitchConfigProxySettings: Codable, Equatable { - let port: Int - let adminKey: String - let autoStart: Bool -} - -struct CoderSwitchConfigAccount: Codable { - let account: Account - let apiKey: String? -} - -enum ConfigBackupError: LocalizedError { - case unsupportedFormat - case unsupportedVersion(Int) - - var errorDescription: String? { - switch self { - case .unsupportedFormat: - return "That file does not look like a CoderSwitch config backup." - case .unsupportedVersion(let version): - return "CoderSwitch config backup version \(version) is not supported." - } - } -} - -@MainActor -enum ConfigBackupStore { - static func exportArchive( - accountStore: AccountStore, - oauthStore: OAuthStore, - proxySettings: ProxySettings - ) -> CoderSwitchConfigArchive { - CoderSwitchConfigArchive( - proxySettings: proxySettings.configExportItem, - accounts: accountStore.configExportItems(), - oauthAccounts: oauthStore.configExportItems() - ) - } - - static func write( - archive: CoderSwitchConfigArchive, - to url: URL - ) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(archive) - try data.write(to: url, options: .atomic) - } - - static func read(from url: URL) throws -> CoderSwitchConfigArchive { - let data = try Data(contentsOf: url) - let archive = try JSONDecoder().decode(CoderSwitchConfigArchive.self, from: data) - guard archive.format == "dev.forbes.CoderSwitch.config" else { - throw ConfigBackupError.unsupportedFormat - } - guard archive.version == 1 else { - throw ConfigBackupError.unsupportedVersion(archive.version) - } - return archive - } - - static func importArchive( - _ archive: CoderSwitchConfigArchive, - accountStore: AccountStore, - oauthStore: OAuthStore, - proxySettings: ProxySettings - ) throws { - try oauthStore.replaceAll(with: archive.oauthAccounts) - try accountStore.replaceAll(with: archive.accounts) - proxySettings.applyConfigImport(archive.proxySettings) - } - - static func deleteAllUserData( - accountStore: AccountStore, - oauthStore: OAuthStore, - proxySettings: ProxySettings, - requestLogStore: RequestLogStore - ) throws { - try oauthStore.replaceAll(with: []) - try accountStore.replaceAll(with: []) - requestLogStore.clear() - proxySettings.resetToDefaults() - } -} diff --git a/CoderSwitch/Stores/KeychainStore.swift b/CoderSwitch/Stores/KeychainStore.swift deleted file mode 100644 index d16f189..0000000 --- a/CoderSwitch/Stores/KeychainStore.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import Security - -enum KeychainError: Error { - case unhandledStatus(OSStatus) -} - -struct KeychainStore { - static let service = "dev.forbes.CoderSwitch" - - static func setSecret(_ value: String, account: String) throws { - let data = Data(value.utf8) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let attributes: [String: Any] = [kSecValueData as String: data] - - let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) - switch status { - case errSecSuccess: - return - case errSecItemNotFound: - var addQuery = query - addQuery[kSecValueData as String] = data - let addStatus = SecItemAdd(addQuery as CFDictionary, nil) - guard addStatus == errSecSuccess else { - throw KeychainError.unhandledStatus(addStatus) - } - default: - throw KeychainError.unhandledStatus(status) - } - } - - static func getSecret(account: String) throws -> String? { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - switch status { - case errSecSuccess: - guard let data = item as? Data, let value = String(data: data, encoding: .utf8) else { - return nil - } - return value - case errSecItemNotFound: - return nil - default: - throw KeychainError.unhandledStatus(status) - } - } - - static func deleteSecret(account: String) throws { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw KeychainError.unhandledStatus(status) - } - } -} diff --git a/CoderSwitch/Stores/OAuthCallbackHandler.swift b/CoderSwitch/Stores/OAuthCallbackHandler.swift deleted file mode 100644 index 464aabf..0000000 --- a/CoderSwitch/Stores/OAuthCallbackHandler.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation -import AppKit - -@MainActor -@Observable -final class OAuthCallbackHandler { - var isProcessing = false - var lastError: String? - - func handle(url: URL) -> Bool { - // No longer needed - we use localhost callback server instead - return false - } -} \ No newline at end of file diff --git a/CoderSwitch/Stores/OAuthStore.swift b/CoderSwitch/Stores/OAuthStore.swift deleted file mode 100644 index 8097f54..0000000 --- a/CoderSwitch/Stores/OAuthStore.swift +++ /dev/null @@ -1,1052 +0,0 @@ -import Foundation -import Observation -import Network -import AppKit -import GRDB - -@Observable -@MainActor -final class OAuthStore { - static let shared = OAuthStore() - - private(set) var accounts: [OAuthAccount] = [] - private var pkceChallenge: PKCEChallenge? - private var callbackListener: NWListener? - private var pendingCompletion: ((Result) -> Void)? - private var lastAuthURL: URL? - - private let db: DatabaseQueue - - private init() { - self.db = AppDatabase.shared.queue - load() - migrateFromJSONIfNeeded() - importCurrentCodexAuthIfNeeded() - importCodexMultiAuthAccountsIfNeeded() - } - - func accounts(for provider: OAuthProvider) -> [OAuthAccount] { - accounts.filter { $0.provider == provider } - } - - func codexAccount(matching account: Account) -> OAuthAccount? { - accounts.first { oauth in - oauth.provider == .codex - && ( - oauth.id == account.id - || (oauth.externalID != nil && oauth.externalID == account.externalID) - || (oauth.email != nil && oauth.email == account.label) - ) - } - } - - func geminiAccount(matching account: Account) -> OAuthAccount? { - accounts.first { oauth in - oauth.provider == .gemini - && ( - oauth.id == account.id - || (oauth.externalID != nil && oauth.externalID == account.externalID) - || (oauth.email != nil && oauth.email == account.label) - ) - } - } - - func add(_ account: OAuthAccount) { - do { - try persist(account) - accounts.append(account) - } catch { - print("OAuthStore.add failed: \(error)") - } - } - - func configExportItems() -> [OAuthAccount] { - accounts - } - - func replaceAll(with importedAccounts: [OAuthAccount]) throws { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let rows = try importedAccounts.map { account in - var stripped = account - stripped.token = OAuthToken( - accessToken: "", - refreshToken: nil, - expiresAt: account.token.expiresAt, - scope: account.token.scope, - idToken: nil - ) - let json = String(data: try encoder.encode(stripped), encoding: .utf8) ?? "{}" - let accessBlob = try SecretBox.seal(account.token.accessToken) - let refreshBlob: Data? = try account.token.refreshToken.map { try SecretBox.seal($0) } - let idTokenBlob: Data? = try account.token.idToken.map { try SecretBox.seal($0) } - return ( - id: account.id.uuidString, - json: json, - access: accessBlob, - refresh: refreshBlob, - idToken: idTokenBlob, - createdAt: Int64(account.createdAt.timeIntervalSince1970) - ) - } - - try db.write { db in - try db.execute(sql: "DELETE FROM oauth_accounts") - for row in rows { - try db.execute( - sql: """ - INSERT INTO oauth_accounts (id, json, access_token_encrypted, refresh_token_encrypted, id_token_encrypted, created_at) - VALUES (?, ?, ?, ?, ?, ?) - """, - arguments: [row.id, row.json, row.access, row.refresh, row.idToken, row.createdAt] - ) - } - } - accounts = importedAccounts - } - - @discardableResult - func importCodexAuth(from url: URL) throws -> CodexAuthImportResult { - try importCodexAuth(from: url, updateExistingSource: true) - } - - @discardableResult - private func importCodexAuth(from url: URL, updateExistingSource: Bool) throws -> CodexAuthImportResult { - let data = try Data(contentsOf: url) - let decoder = JSONDecoder() - let auth: CodexAuthFile - do { - auth = try decoder.decode(CodexAuthFile.self, from: data) - } catch { - throw OAuthError.invalidCodexAuthFile - } - guard auth.authMode == "chatgpt" else { - throw OAuthError.unsupportedCodexAuthMode(auth.authMode) - } - return try importCodexAuth(auth, authSource: .json, updateExistingSource: updateExistingSource) - } - - func delete(_ account: OAuthAccount) { - let id = account.id.uuidString - try? db.write { db in - try db.execute(sql: "DELETE FROM oauth_accounts WHERE id = ?", arguments: [id]) - } - deleteCLIProxyAuth(account: account) - accounts.removeAll { $0.id == account.id } - } - - func updateToken(for accountID: UUID, token: OAuthToken) { - guard let idx = accounts.firstIndex(where: { $0.id == accountID }) else { return } - accounts[idx].token = OAuthToken( - accessToken: token.accessToken, - refreshToken: token.refreshToken ?? accounts[idx].token.refreshToken, - expiresAt: token.expiresAt, - scope: token.scope, - idToken: token.idToken ?? accounts[idx].token.idToken - ) - try? persist(accounts[idx]) - } - - func startOAuthFlow(provider: OAuthProvider) async throws -> URL? { - pkceChallenge = PKCEChallenge() - - try await startCallbackServer(for: provider) - - var components = URLComponents(url: provider.authURL, resolvingAgainstBaseURL: false)! - var queryItems: [URLQueryItem] = [ - URLQueryItem(name: "client_id", value: provider.clientID), - URLQueryItem(name: "redirect_uri", value: redirectURI(for: provider)), - URLQueryItem(name: "response_type", value: "code"), - URLQueryItem(name: "scope", value: provider.scopes), - URLQueryItem(name: "code_challenge", value: pkceChallenge?.challenge), - URLQueryItem(name: "code_challenge_method", value: "S256"), - ] - - if provider == .codex { - queryItems.append(URLQueryItem(name: "originator", value: provider.originator)) - queryItems.append(URLQueryItem(name: "codex_cli_simplified_flow", value: "true")) - queryItems.append(URLQueryItem(name: "id_token_add_organizations", value: "true")) - queryItems.append(URLQueryItem(name: "prompt", value: "login")) - } else if provider == .gemini { - queryItems.append(URLQueryItem(name: "access_type", value: "offline")) - queryItems.append(URLQueryItem(name: "prompt", value: "consent")) - } - - components.queryItems = queryItems - return components.url - } - - func initiateOAuthFlow(provider: OAuthProvider, completion: @escaping (Result) -> Void) async throws { - let authURL = try await startOAuthFlow(provider: provider) - pendingCompletion = completion - lastAuthURL = authURL - if let authURL { NSWorkspace.shared.open(authURL) } - } - - func currentAuthURL(for provider: OAuthProvider) async throws -> URL? { - lastAuthURL - } - - func cancelOAuthFlow() { - stopCallbackServer() - pkceChallenge = nil - lastAuthURL = nil - let completion = pendingCompletion - pendingCompletion = nil - completion?(.failure(OAuthError.cancelled)) - } - - var hasPendingFlow: Bool { - pendingCompletion != nil || callbackListener != nil - } - - func handleAuthCallback( - provider: OAuthProvider, - code: String, - completion: @escaping (Result) -> Void - ) { - pendingCompletion = completion - completeAuthCallback(provider: provider, code: code) - } - - private func completeAuthCallback(provider: OAuthProvider, code: String) { - guard let challenge = pkceChallenge else { - finishPending(.failure(OAuthError.missingPKCEChallenge)) - return - } - - pkceChallenge = nil - - Task { - do { - let tokenResponse = try await exchangeCode( - provider: provider, - code: code, - verifier: challenge.verifier - ) - let account = try await createAccount( - provider: provider, - token: tokenResponse.toOAuthToken() - ) - try persist(account) - accounts.append(account) - try exportToCLIProxyAPI(account: account) - stopCallbackServer() - finishPending(.success(account)) - } catch { - stopCallbackServer() - finishPending(.failure(error)) - } - } - } - - private func finishPending(_ result: Result) { - let completion = pendingCompletion - pendingCompletion = nil - lastAuthURL = nil - completion?(result) - } - - func refreshTokenIfNeeded(for account: OAuthAccount) async throws -> OAuthToken { - let shouldRefresh = account.token.isNearExpiry || (account.provider == .codex && account.token.idToken == nil) - guard shouldRefresh, let refreshToken = account.token.refreshToken else { - return account.token - } - - let provider = account.provider - var request = URLRequest(url: provider.tokenURL) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - var body: [String: String] = [ - "client_id": provider.clientID, - "refresh_token": refreshToken, - "grant_type": "refresh_token", - ] - - if let clientSecret = provider.clientSecret { - body["client_secret"] = clientSecret - } - - request.httpBody = URLSession.urlEncodedForm(body) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw OAuthError.tokenRefreshFailed - } - - let tokenResponse = try JSONDecoder().decode(OAuthTokenResponse.self, from: data) - let newToken = tokenResponse.toOAuthToken( - fallbackRefreshToken: account.token.refreshToken, - fallbackIDToken: account.token.idToken - ) - updateToken(for: account.id, token: newToken) - return newToken - } - - func switchToAccount(_ account: OAuthAccount) async throws { - let token = try await refreshTokenIfNeeded(for: account) - var refreshed = account - refreshed.token = token - switch account.provider { - case .codex: - try exportCodexAuthJSON(account: refreshed) - try exportCodexCLIProxyAuth(account: refreshed) - case .gemini: - try exportGeminiCLIProxyAuth(account: refreshed) - } - } - - private func startCallbackServer(for provider: OAuthProvider) async throws { - stopCallbackServer() - - let port = NWEndpoint.Port(integerLiteral: provider.callbackPort) - - callbackListener = try NWListener(using: .tcp, on: port) - callbackListener?.stateUpdateHandler = { state in - switch state { - case .ready: - print("OAuth callback server ready on port \(provider.callbackPort)") - case .failed(let error): - print("OAuth callback server failed: \(error)") - default: - break - } - } - - let providerCopy = provider - callbackListener?.newConnectionHandler = { [weak self] connection in - Task { @MainActor in - self?.handleCallbackConnection(connection, provider: providerCopy) - } - } - - callbackListener?.start(queue: .main) - } - - private func handleCallbackConnection(_ connection: NWConnection, provider: OAuthProvider) { - connection.start(queue: .main) - - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, _, _ in - let request = data.flatMap { String(data: $0, encoding: .utf8) } - Task { @MainActor in - guard let self, - let request, - !request.isEmpty else { - connection.cancel() - return - } - - if let url = self.extractCallbackURL(from: request, provider: provider) { - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - let code = components?.queryItems?.first(where: { $0.name == "code" })?.value - let errorParam = components?.queryItems?.first(where: { $0.name == "error" })?.value - - if let error = errorParam { - print("OAuth error: \(error)") - self.finishPending(.failure(OAuthError.providerError(error))) - self.stopCallbackServer() - self.pkceChallenge = nil - } else if let code = code { - self.completeAuthCallback(provider: provider, code: code) - } - } - - let redirectHTML = """ - - - Authenticated - -

Authentication successful! You can close this window.

- - - - """ - - let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: \(redirectHTML.count)\r\n\r\n\(redirectHTML)" - connection.send(content: response.data(using: .utf8), completion: .contentProcessed { _ in - connection.cancel() - }) - } - } - } - - private func extractCallbackURL(from request: String, provider: OAuthProvider) -> URL? { - let lines = request.components(separatedBy: "\r\n") - for line in lines { - if line.hasPrefix("GET ") { - let parts = line.split(separator: " ", maxSplits: 2) - if parts.count >= 2 { - let path = String(parts[1]) - return URL(string: "coderswitch://localhost\(path)") - } - } - } - return nil - } - - private func stopCallbackServer() { - callbackListener?.cancel() - callbackListener = nil - } - - private func exportToCLIProxyAPI(account: OAuthAccount) throws { - switch account.provider { - case .codex: - try exportCodexCLIProxyAuth(account: account) - case .gemini: - try exportGeminiCLIProxyAuth(account: account) - } - } - - private func deleteCLIProxyAuth(account: OAuthAccount) { - let filename: String - switch account.provider { - case .codex: - filename = cliProxyCodexFilename(for: account) - case .gemini: - filename = cliProxyGeminiFilename(for: account) - } - try? FileManager.default.removeItem(at: cliProxyAuthDir().appendingPathComponent(filename)) - } - - private func exportCodexAuthJSON(account: OAuthAccount) throws { - let token = account.token - guard let idToken = token.idToken else { - throw OAuthError.missingIDToken - } - - let configDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex", isDirectory: true) - let authFile = configDir.appendingPathComponent("auth.json") - - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) - - let claims = CodexAuthClaims.fromIDToken(idToken) - let auth = CodexAuthFile( - authMode: "chatgpt", - openAIAPIKey: nil, - tokens: CodexAuthTokens( - idToken: idToken, - accessToken: token.accessToken, - refreshToken: token.refreshToken, - accountID: account.externalID ?? claims?.accountID - ), - lastRefresh: ISO8601DateFormatter().string(from: Date()), - email: account.email ?? claims?.email, - codexMultiAuthSyncVersion: Int64(Date().timeIntervalSince1970 * 1000) - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(auth) - try writeConfigFile(data, to: authFile) - } - - private func exportCodexCLIProxyAuth(account: OAuthAccount) throws { - let token = account.token - guard let idToken = token.idToken else { - throw OAuthError.missingIDToken - } - - let configDir = cliProxyAuthDir() - let authFile = configDir.appendingPathComponent(cliProxyCodexFilename(for: account)) - - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) - - let claims = CodexAuthClaims.fromIDToken(idToken) - let auth = CLIProxyCodexAuthFile( - idToken: idToken, - accessToken: token.accessToken, - refreshToken: token.refreshToken ?? "", - accountID: account.externalID ?? claims?.accountID ?? "", - lastRefresh: ISO8601DateFormatter().string(from: Date()), - email: account.email ?? claims?.email, - type: "codex", - expired: cliProxyExpiryString(for: token, fallback: claims?.expiresAt) - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(auth) - try writeConfigFile(data, to: authFile) - } - - private func exportGeminiCLIProxyAuth(account: OAuthAccount) throws { - let token = account.token - let configDir = cliProxyAuthDir() - let authFile = configDir.appendingPathComponent(cliProxyGeminiFilename(for: account)) - - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) - - let auth = CLIProxyGeminiAuthFile( - token: CLIProxyGeminiToken( - accessToken: token.accessToken, - refreshToken: token.refreshToken ?? "", - tokenType: "Bearer", - expiry: cliProxyExpiryString(for: token, fallback: nil), - tokenURI: OAuthProvider.gemini.tokenURL.absoluteString, - clientID: OAuthProvider.gemini.clientID, - clientSecret: OAuthProvider.gemini.clientSecret ?? "", - scopes: OAuthProvider.gemini.scopes.components(separatedBy: " "), - universeDomain: "googleapis.com" - ), - projectID: "all", - email: account.email ?? account.label, - auto: true, - checked: false, - type: "gemini" - ) - - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let data = try encoder.encode(auth) - try writeConfigFile(data, to: authFile) - } - - private func redirectURI(for provider: OAuthProvider) -> String { - "http://localhost:\(provider.callbackPort)\(provider.callbackPath)" - } - - private func exchangeCode( - provider: OAuthProvider, - code: String, - verifier: String - ) async throws -> OAuthTokenResponse { - var request = URLRequest(url: provider.tokenURL) - request.httpMethod = "POST" - request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - - var body: [String: String] = [ - "client_id": provider.clientID, - "code": code, - "code_verifier": verifier, - "grant_type": "authorization_code", - "redirect_uri": redirectURI(for: provider), - ] - - if let clientSecret = provider.clientSecret { - body["client_secret"] = clientSecret - } - - request.httpBody = URLSession.urlEncodedForm(body) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw OAuthError.tokenExchangeFailed - } - return try JSONDecoder().decode(OAuthTokenResponse.self, from: data) - } - - private func createAccount( - provider: OAuthProvider, - token: OAuthToken - ) async throws -> OAuthAccount { - let codexClaims = token.idToken.flatMap(CodexAuthClaims.fromIDToken) - let fetchedEmail: String? - if let email = codexClaims?.email { - fetchedEmail = email - } else { - fetchedEmail = try await fetchEmail(for: provider, token: token) - } - let email = fetchedEmail - let label = email ?? "\(provider.displayName) \(accounts.filter { $0.provider == provider }.count + 1)" - return OAuthAccount( - label: label, - provider: provider, - email: email, - externalID: codexClaims?.accountID, - token: token - ) - } - - private func fetchEmail(for provider: OAuthProvider, token: OAuthToken) async throws -> String? { - var request = URLRequest(url: provider.userInfoURL) - request.setValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization") - let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - return nil - } - struct UserInfo: Decodable { let email: String? } - return try JSONDecoder().decode(UserInfo.self, from: data).email - } - - private func persist(_ account: OAuthAccount) throws { - var stripped = account - stripped.token = OAuthToken( - accessToken: "", - refreshToken: nil, - expiresAt: account.token.expiresAt, - scope: account.token.scope, - idToken: nil - ) - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let json = String(data: try encoder.encode(stripped), encoding: .utf8) ?? "{}" - let accessBlob = try SecretBox.seal(account.token.accessToken) - let refreshBlob: Data? = try account.token.refreshToken.map { try SecretBox.seal($0) } - let idTokenBlob: Data? = try account.token.idToken.map { try SecretBox.seal($0) } - let id = account.id.uuidString - let createdAt = Int64(account.createdAt.timeIntervalSince1970) - try db.write { db in - try db.execute( - sql: """ - INSERT INTO oauth_accounts (id, json, access_token_encrypted, refresh_token_encrypted, id_token_encrypted, created_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - json = excluded.json, - access_token_encrypted = excluded.access_token_encrypted, - refresh_token_encrypted = excluded.refresh_token_encrypted, - id_token_encrypted = excluded.id_token_encrypted - """, - arguments: [id, json, accessBlob, refreshBlob, idTokenBlob, createdAt] - ) - } - } - - private func load() { - struct LoadedRow { - let json: Data - let access: Data - let refresh: Data? - let idToken: Data? - } - let rows: [LoadedRow] = (try? db.read { db in - try GRDB.Row.fetchAll(db, sql: """ - SELECT json, access_token_encrypted, refresh_token_encrypted, id_token_encrypted - FROM oauth_accounts ORDER BY created_at ASC - """).map { row in - LoadedRow( - json: Data((row["json"] as String).utf8), - access: row["access_token_encrypted"], - refresh: row["refresh_token_encrypted"] as Data?, - idToken: row["id_token_encrypted"] as Data? - ) - } - }) ?? [] - let decoder = JSONDecoder() - accounts = rows.compactMap { row in - guard var account = try? decoder.decode(OAuthAccount.self, from: row.json), - let access = try? SecretBox.open(row.access) else { return nil } - let refresh = row.refresh.flatMap { try? SecretBox.open($0) } - let idToken = row.idToken.flatMap { try? SecretBox.open($0) } - account.token = OAuthToken( - accessToken: access, - refreshToken: refresh, - expiresAt: account.token.expiresAt, - scope: account.token.scope, - idToken: idToken - ) - return account - } - } - - private func migrateFromJSONIfNeeded() { - guard accounts.isEmpty else { return } - let dir = FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("CoderSwitch", isDirectory: true) - let jsonURL = dir.appendingPathComponent("oauth_accounts.json") - guard let data = try? Data(contentsOf: jsonURL), - let decoded = try? JSONDecoder().decode([OAuthAccount].self, from: data) else { - return - } - for account in decoded { - do { - try persist(account) - accounts.append(account) - } catch { - continue - } - } - try? FileManager.default.removeItem(at: jsonURL) - } - - private func importCurrentCodexAuthIfNeeded() { - let authURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex") - .appendingPathComponent("auth.json") - - do { - try importCodexAuth(from: authURL, updateExistingSource: false) - } catch { - return - } - } - - private func importCodexAuth( - _ auth: CodexAuthFile, - authSource: OAuthAccountAuthSource, - updateExistingSource: Bool - ) throws -> CodexAuthImportResult { - let claims = CodexAuthClaims.fromIDToken(auth.tokens.idToken) - let externalID = auth.tokens.accountID ?? claims?.accountID - let email = auth.email ?? claims?.email - let token = OAuthToken( - accessToken: auth.tokens.accessToken, - refreshToken: auth.tokens.refreshToken, - expiresAt: claims?.expiresAt, - scope: claims?.scope, - idToken: auth.tokens.idToken - ) - - if let idx = accounts.firstIndex(where: { account in - account.provider == .codex - && ( - (externalID != nil && account.externalID == externalID) - || (email != nil && account.email == email) - ) - }) { - accounts[idx].email = email ?? accounts[idx].email - accounts[idx].externalID = externalID ?? accounts[idx].externalID - accounts[idx].token = token - if updateExistingSource { - accounts[idx].authSource = authSource - } - try persist(accounts[idx]) - return CodexAuthImportResult(account: accounts[idx], didUpdate: true) - } - - let account = OAuthAccount( - label: email ?? "Current Codex Login", - provider: .codex, - email: email, - externalID: externalID, - token: token, - authSource: authSource - ) - - try persist(account) - accounts.append(account) - return CodexAuthImportResult(account: account, didUpdate: false) - } - - private func importCodexMultiAuthAccountsIfNeeded() { - let multiAuthURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".codex") - .appendingPathComponent("multi-auth") - .appendingPathComponent("openai-codex-accounts.json") - - guard let data = try? Data(contentsOf: multiAuthURL), - let store = try? JSONDecoder().decode(CodexMultiAuthStore.self, from: data) else { - return - } - - for item in store.accounts where item.enabled { - let existingIndex = accounts.firstIndex { account in - account.provider == .codex - && ( - (item.accountID != nil && account.externalID == item.accountID) - || (item.email != nil && account.email == item.email) - ) - } - - let expiresAt = item.expiresAt.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000) } - let createdAt = Date(timeIntervalSince1970: TimeInterval(item.addedAt ?? Int64(Date().timeIntervalSince1970 * 1000)) / 1000) - if let existingIndex { - accounts[existingIndex].label = item.accountLabel ?? accounts[existingIndex].label - accounts[existingIndex].email = item.email ?? accounts[existingIndex].email - accounts[existingIndex].externalID = item.accountID ?? accounts[existingIndex].externalID - accounts[existingIndex].token = OAuthToken( - accessToken: item.accessToken, - refreshToken: item.refreshToken ?? accounts[existingIndex].token.refreshToken, - expiresAt: expiresAt, - scope: accounts[existingIndex].token.scope, - idToken: accounts[existingIndex].token.idToken - ) - do { - try persist(accounts[existingIndex]) - } catch { - print("OAuthStore.importCodexMultiAuthAccountsIfNeeded failed: \(error)") - } - continue - } - - let account = OAuthAccount( - label: item.accountLabel ?? item.email ?? "Codex Account", - provider: .codex, - email: item.email, - externalID: item.accountID, - token: OAuthToken( - accessToken: item.accessToken, - refreshToken: item.refreshToken, - expiresAt: expiresAt, - scope: nil - ), - createdAt: createdAt, - authSource: .json - ) - - do { - try persist(account) - accounts.append(account) - } catch { - print("OAuthStore.importCodexMultiAuthAccountsIfNeeded failed: \(error)") - } - } - } - - private func cliProxyAuthDir() -> URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".cli-proxy-api", isDirectory: true) - } - - private func writeConfigFile(_ data: Data, to url: URL) throws { - let fm = FileManager.default - let backupURL = url.deletingLastPathComponent() - .appendingPathComponent("\(url.lastPathComponent).coderswitch.bak") - let temporaryBackupURL = url.deletingLastPathComponent() - .appendingPathComponent("\(url.lastPathComponent).coderswitch.bak.\(UUID().uuidString).tmp") - let hadExistingFile = fm.fileExists(atPath: url.path) - - if hadExistingFile { - try fm.copyItem(at: url, to: temporaryBackupURL) - } - - do { - try data.write(to: url, options: .atomic) - try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) - if hadExistingFile { - if fm.fileExists(atPath: backupURL.path) { - try fm.removeItem(at: backupURL) - } - try fm.moveItem(at: temporaryBackupURL, to: backupURL) - } - } catch { - if hadExistingFile, fm.fileExists(atPath: temporaryBackupURL.path) { - try? fm.removeItem(at: url) - try? fm.copyItem(at: temporaryBackupURL, to: url) - try? fm.removeItem(at: temporaryBackupURL) - } - throw error - } - } - - private func cliProxyCodexFilename(for account: OAuthAccount) -> String { - let email = account.email ?? account.label - return "codex-\(safeFilenameComponent(email)).json" - } - - private func cliProxyGeminiFilename(for account: OAuthAccount) -> String { - let email = account.email ?? account.label - return "gemini-\(safeFilenameComponent(email))-all.json" - } - - private func safeFilenameComponent(_ value: String) -> String { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "@._-+")) - let scalars = value.unicodeScalars.map { scalar in - allowed.contains(scalar) ? Character(scalar) : "-" - } - let name = String(scalars).trimmingCharacters(in: CharacterSet(charactersIn: ".- ")) - return name.isEmpty ? UUID().uuidString : name - } - - private func cliProxyExpiryString(for token: OAuthToken, fallback: Date?) -> String { - let expiry = token.expiresAt ?? fallback ?? Date().addingTimeInterval(3600) - return ISO8601DateFormatter().string(from: expiry) - } -} - -enum OAuthError: LocalizedError { - case missingAuthorizationCode - case missingPKCEChallenge - case missingIDToken - case tokenExchangeFailed - case tokenRefreshFailed - case callbackServerFailed - case cancelled - case providerError(String) - case invalidCodexAuthFile - case unsupportedCodexAuthMode(String) - - var errorDescription: String? { - switch self { - case .missingAuthorizationCode: return "No authorization code in callback" - case .missingPKCEChallenge: return "PKCE challenge not found" - case .missingIDToken: return "Codex account is missing an id_token and cannot be exported to CLIProxyAPI" - case .tokenExchangeFailed: return "Failed to exchange code for token" - case .tokenRefreshFailed: return "Failed to refresh token" - case .callbackServerFailed: return "Failed to start callback server" - case .cancelled: return "Authentication cancelled" - case .providerError(let msg): return "Provider error: \(msg)" - case .invalidCodexAuthFile: return "That file does not look like a Codex auth.json file" - case .unsupportedCodexAuthMode(let mode): return "Cannot import Codex auth mode '\(mode)'. Sign in with ChatGPT auth first." - } - } -} - -struct CodexAuthImportResult { - let account: OAuthAccount - let didUpdate: Bool -} - -struct CLIProxyCodexAuthFile: Codable { - let idToken: String - let accessToken: String - let refreshToken: String - let accountID: String - let lastRefresh: String - let email: String? - let type: String - let expired: String - - enum CodingKeys: String, CodingKey { - case idToken = "id_token" - case accessToken = "access_token" - case refreshToken = "refresh_token" - case accountID = "account_id" - case lastRefresh = "last_refresh" - case email - case type - case expired - } -} - -struct CLIProxyGeminiAuthFile: Codable { - let token: CLIProxyGeminiToken - let projectID: String - let email: String - let auto: Bool - let checked: Bool - let type: String - - enum CodingKeys: String, CodingKey { - case token - case projectID = "project_id" - case email - case auto - case checked - case type - } -} - -struct CLIProxyGeminiToken: Codable { - let accessToken: String - let refreshToken: String - let tokenType: String - let expiry: String - let tokenURI: String - let clientID: String - let clientSecret: String - let scopes: [String] - let universeDomain: String - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case tokenType = "token_type" - case expiry - case tokenURI = "token_uri" - case clientID = "client_id" - case clientSecret = "client_secret" - case scopes - case universeDomain = "universe_domain" - } -} - -struct CodexAuthFile: Codable { - let authMode: String - let openAIAPIKey: String? - let tokens: CodexAuthTokens - let lastRefresh: String - let email: String? - let codexMultiAuthSyncVersion: Int64? - - enum CodingKeys: String, CodingKey { - case authMode = "auth_mode" - case openAIAPIKey = "OPENAI_API_KEY" - case tokens - case lastRefresh = "last_refresh" - case email - case codexMultiAuthSyncVersion - } -} - -struct CodexAuthTokens: Codable { - let idToken: String - let accessToken: String - let refreshToken: String? - let accountID: String? - - enum CodingKeys: String, CodingKey { - case idToken = "id_token" - case accessToken = "access_token" - case refreshToken = "refresh_token" - case accountID = "account_id" - } -} - -struct CodexAuthClaims { - let email: String? - let accountID: String? - let expiresAt: Date? - let scope: String? - - static func fromIDToken(_ idToken: String) -> CodexAuthClaims? { - let parts = idToken.split(separator: ".") - guard parts.count >= 2, - let payload = base64URLDecode(String(parts[1])), - let object = try? JSONSerialization.jsonObject(with: payload) as? [String: Any] else { - return nil - } - - let profile = object["https://api.openai.com/profile"] as? [String: Any] - let auth = object["https://api.openai.com/auth"] as? [String: Any] - let expiresAt = (object["exp"] as? TimeInterval).map { Date(timeIntervalSince1970: $0) } - let scopes = (object["scp"] as? [String])?.joined(separator: " ") - - return CodexAuthClaims( - email: profile?["email"] as? String, - accountID: auth?["chatgpt_account_id"] as? String, - expiresAt: expiresAt, - scope: scopes - ) - } - - private static func base64URLDecode(_ value: String) -> Data? { - var base64 = value - .replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let remainder = base64.count % 4 - if remainder > 0 { - base64 += String(repeating: "=", count: 4 - remainder) - } - return Data(base64Encoded: base64) - } -} - -struct CodexMultiAuthStore: Decodable { - let accounts: [CodexMultiAuthAccount] -} - -struct CodexMultiAuthAccount: Decodable { - let accountID: String? - let accountLabel: String? - let email: String? - let refreshToken: String? - let accessToken: String - let expiresAt: Int64? - let enabled: Bool - let addedAt: Int64? - - enum CodingKeys: String, CodingKey { - case accountID = "accountId" - case accountLabel - case email - case refreshToken - case accessToken - case expiresAt - case enabled - case addedAt - } -} - -extension URLSession { - static func urlEncodedForm(_ dict: [String: String]) -> Data? { - var components = URLComponents() - components.queryItems = dict.map { URLQueryItem(name: $0.key, value: $0.value) } - return components.percentEncodedQuery?.data(using: .utf8) - } -} diff --git a/CoderSwitch/Stores/RequestLogStore.swift b/CoderSwitch/Stores/RequestLogStore.swift deleted file mode 100644 index 91a03c5..0000000 --- a/CoderSwitch/Stores/RequestLogStore.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation -import Observation -import GRDB - -@Observable -@MainActor -final class RequestLogStore { - private(set) var logs: [ProxyRequestLog] = [] - - private let db: DatabaseQueue - private let maxVisibleLogs = 200 - private let maxPersistedLogs = 2_000 - - init() { - self.db = AppDatabase.shared.queue - load() - } - - func record(_ log: ProxyRequestLog) { - do { - try persist(log) - logs.insert(log, at: 0) - if logs.count > maxVisibleLogs { - logs.removeLast(logs.count - maxVisibleLogs) - } - try prune() - } catch { - print("RequestLogStore.record failed: \(error)") - } - } - - func clear() { - do { - try db.write { db in - try db.execute(sql: "DELETE FROM request_logs") - } - logs.removeAll() - } catch { - print("RequestLogStore.clear failed: \(error)") - } - } - - private func load() { - logs = (try? db.read { db in - try Row.fetchAll(db, sql: """ - SELECT * - FROM request_logs - ORDER BY timestamp DESC - LIMIT ? - """, arguments: [maxVisibleLogs]).compactMap(Self.decode) - }) ?? [] - } - - private func persist(_ log: ProxyRequestLog) throws { - try db.write { db in - try db.execute( - sql: """ - INSERT INTO request_logs ( - id, - timestamp, - method, - path, - account_id, - account_label, - provider, - model, - upstream_model, - status_code, - latency_ms, - input_tokens, - output_tokens, - cache_read_tokens, - cache_write_tokens, - uncategorized_tokens, - error_message - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - arguments: [ - log.id.uuidString, - Int64(log.timestamp.timeIntervalSince1970 * 1000), - log.method, - log.path, - log.accountID?.uuidString, - log.accountLabel, - log.provider?.rawValue, - log.model, - log.upstreamModel, - log.statusCode, - log.latencyMS, - log.usage.inputTokens, - log.usage.outputTokens, - log.usage.cacheReadTokens, - log.usage.cacheWriteTokens, - log.usage.uncategorizedTokens, - log.errorMessage - ] - ) - } - } - - private func prune() throws { - try db.write { db in - try db.execute(sql: """ - DELETE FROM request_logs - WHERE id NOT IN ( - SELECT id - FROM request_logs - ORDER BY timestamp DESC - LIMIT ? - ) - """, arguments: [maxPersistedLogs]) - } - } - - private static func decode(_ row: Row) -> ProxyRequestLog? { - guard let id = UUID(uuidString: row["id"] as String), - let timestampMS = row["timestamp"] as Int64? else { - return nil - } - let accountIDString = row["account_id"] as String? - let providerRaw = row["provider"] as String? - return ProxyRequestLog( - id: id, - timestamp: Date(timeIntervalSince1970: TimeInterval(timestampMS) / 1000), - method: row["method"] as String, - path: row["path"] as String, - accountID: accountIDString.flatMap(UUID.init(uuidString:)), - accountLabel: row["account_label"] as String?, - provider: providerRaw.flatMap(Provider.init(rawValue:)), - model: row["model"] as String?, - upstreamModel: row["upstream_model"] as String?, - statusCode: row["status_code"] as Int?, - latencyMS: row["latency_ms"] as Int, - usage: TokenUsageDelta( - inputTokens: row["input_tokens"] as Int, - outputTokens: row["output_tokens"] as Int, - cacheReadTokens: row["cache_read_tokens"] as Int, - cacheWriteTokens: row["cache_write_tokens"] as Int, - uncategorizedTokens: row["uncategorized_tokens"] as Int - ), - errorMessage: row["error_message"] as String? - ) - } -} diff --git a/CoderSwitch/Views/AboutView.swift b/CoderSwitch/Views/AboutView.swift index c1b4691..9d44033 100644 --- a/CoderSwitch/Views/AboutView.swift +++ b/CoderSwitch/Views/AboutView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore struct AboutView: View { @Environment(\.dismiss) private var dismiss diff --git a/CoderSwitch/Views/AccountsTab.swift b/CoderSwitch/Views/AccountsTab.swift index 1cd64a5..b7d533c 100644 --- a/CoderSwitch/Views/AccountsTab.swift +++ b/CoderSwitch/Views/AccountsTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore import AppKit struct AccountsTab: View { @@ -148,8 +149,7 @@ struct AccountRow: View { @State private var healthMessage: String? @State private var confirmingDelete = false @State private var showingBreaker = false - @State private var showingRename = false - @State private var nicknameDraft = "" + @State private var showingEdit = false @State private var claudeLaunchError: String? @State private var showingGoogleModels = true @@ -201,6 +201,11 @@ struct AccountRow: View { .font(.caption2) .foregroundStyle(.secondary) } + if let accessSummary { + Text(accessSummary) + .font(.caption2) + .foregroundStyle(accessSummaryColor) + } if let lastChecked = account.lastCheckedAt { Text("Checked \(lastChecked, style: .relative) ago") .font(.caption2) @@ -216,13 +221,12 @@ struct AccountRow: View { .buttonStyle(.borderless) .help(account.isEnabled ? "Disable routing and quota polling" : "Enable routing and quota polling") Button { - nicknameDraft = account.label - showingRename = true + showingEdit = true } label: { Image(systemName: "pencil") } .buttonStyle(.borderless) - .help("Edit nickname") + .help("Edit account") Button { showingBreaker = true } label: { @@ -289,6 +293,22 @@ struct AccountRow: View { .foregroundStyle(.tertiary) } + if account.provider.isProxyRoutable && account.provider.supportsUsagePolling { + if let error = account.lastCheckError { + Text(error) + .font(.caption2) + .foregroundStyle(.orange) + } else if account.usageLimits.isEmpty { + Text("Pending first quota check...") + .font(.caption2) + .foregroundStyle(.secondary) + } else { + ForEach(account.usageLimits) { limit in + AccountLimitBar(limit: limit) + } + } + } + if let fetchError { Text(fetchError) .font(.caption2) @@ -308,7 +328,7 @@ struct AccountRow: View { Label("Open Claude Code", systemImage: "terminal") } .buttonStyle(.bordered) - .help("Choose a folder and launch Claude Code through this account") + .help("Choose a folder and launch Claude Code with this account's endpoint and API key") } if let claudeLaunchError { @@ -330,25 +350,10 @@ struct AccountRow: View { .sheet(isPresented: $showingBreaker) { CircuitBreakerSheet(account: account) } - .sheet(isPresented: $showingRename) { - VStack(alignment: .leading, spacing: 16) { - Text("Edit Nickname") - .font(.headline) - TextField("Nickname", text: $nicknameDraft) - .textFieldStyle(.roundedBorder) - HStack { - Spacer() - Button("Cancel") { showingRename = false } - Button("Save") { - store.rename(accountID: account.id, label: nicknameDraft) - showingRename = false - } - .keyboardShortcut(.defaultAction) - .disabled(nicknameDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } + .sheet(isPresented: $showingEdit) { + NavigationStack { + EditAccountView(account: account) } - .padding() - .frame(width: 360) } } @@ -356,8 +361,9 @@ struct AccountRow: View { switch account.provider { case .codex: return .blue case .openRouter: return .purple - case .miniMax: return .pink - case .anthropic, .anthropicCompatible: return .orange + case .opencodeGo: return .teal + case .miniMax, .glmCodingPlan: return .pink + case .anthropic, .anthropicCompatible, .kimiCodingPlan, .qwenCodingPlan: return .orange case .openAI, .openAICompatible: return .green case .googleAntigravity: return .indigo } @@ -377,6 +383,29 @@ struct AccountRow: View { ) } + private var accessSummary: String? { + let start = account.accessGainedAt + let duration = account.accessDuration + guard start != nil || duration != nil else { return nil } + + var parts: [String] = [] + if let start { + parts.append("Access: \(start.formatted(date: .abbreviated, time: .omitted))") + } + if let duration { + parts.append("Period: \(duration.value) \(duration.unit.displayName(for: duration.value))") + } + if let expiry = account.accessExpiresAt() { + parts.append("Expires: \(expiry.formatted(date: .abbreviated, time: .omitted))") + } + return parts.joined(separator: " | ") + } + + private var accessSummaryColor: Color { + guard let expiry = account.accessExpiresAt() else { return .secondary } + return expiry < Date() ? .orange : .secondary + } + private func fetchModels() async { isFetchingModels = true fetchError = nil @@ -413,6 +442,7 @@ struct AccountRow: View { do { try ClaudeCodeLauncher.open( account: account, + apiKey: store.apiKey(for: account), proxySettings: proxySettings, proxyManager: proxyManager ) @@ -424,147 +454,238 @@ struct AccountRow: View { } } -private enum ClaudeCodeLaunchError: LocalizedError { - case incompatibleAccount - case cancelled - case terminalLaunchFailed - - var errorDescription: String? { - switch self { - case .incompatibleAccount: - return "This account cannot be used with Claude Code." - case .cancelled: - return nil - case .terminalLaunchFailed: - return "Could not open Claude Code in Terminal." - } +private struct EditAccountView: View { + @Environment(AccountStore.self) private var store + @Environment(\.dismiss) private var dismiss + + let account: Account + + @State private var provider: Provider + @State private var label: String + @State private var apiKey: String = "" + @State private var customEndpoint: String + @State private var defaultModel: String + @State private var claudeOpusModel: String + @State private var claudeSonnetModel: String + @State private var claudeHaikuModel: String + @State private var hasAccessGainedAt: Bool + @State private var accessGainedAt: Date + @State private var hasAccessDuration: Bool + @State private var accessDurationValue: String + @State private var accessDurationUnit: AccountAccessDurationUnit + @State private var isEnabled: Bool + @State private var errorMessage: String? + + private let apiKeyProviders = Provider.allCases.filter(\.canAddWithAPIKey) + + init(account: Account) { + self.account = account + _provider = State(initialValue: account.provider) + _label = State(initialValue: account.label) + _customEndpoint = State(initialValue: account.customEndpoint ?? "") + _defaultModel = State(initialValue: account.defaultModel ?? "") + _claudeOpusModel = State(initialValue: account.claudeCodeModelOverrides?.opus ?? "") + _claudeSonnetModel = State(initialValue: account.claudeCodeModelOverrides?.sonnet ?? "") + _claudeHaikuModel = State(initialValue: account.claudeCodeModelOverrides?.haiku ?? "") + _hasAccessGainedAt = State(initialValue: account.accessGainedAt != nil) + _accessGainedAt = State(initialValue: account.accessGainedAt ?? Date()) + _hasAccessDuration = State(initialValue: account.accessDuration != nil) + _accessDurationValue = State(initialValue: account.accessDuration.map { String($0.value) } ?? "1") + _accessDurationUnit = State(initialValue: account.accessDuration?.unit ?? .month) + _isEnabled = State(initialValue: account.isEnabled) } -} -private enum ClaudeCodeLauncher { - @MainActor - static func open( - account: Account, - proxySettings: ProxySettings, - proxyManager: ProxyManager - ) throws { - guard account.provider.isClaudeCodeCompatible else { - throw ClaudeCodeLaunchError.incompatibleAccount - } - guard let folder = chooseFolder() else { - throw ClaudeCodeLaunchError.cancelled - } + private var canEditAPIProfile: Bool { + account.provider.canAddWithAPIKey + } - switch proxyManager.status { - case .running, .starting: - break - default: - proxyManager.start() + private var canSave: Bool { + guard !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if hasAccessDuration && parsedAccessDuration == nil { return false } + if provider.requiresCustomEndpoint { + return !customEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + return true + } - try ClaudeCodeConfigSwitcher().switchToCoderSwitch( - account: account, - proxyBaseURL: proxySettings.baseURL, - adminKey: proxySettings.adminKey - ) + private var parsedAccessDuration: AccountAccessDuration? { + guard hasAccessDuration else { return nil } + guard let value = Int(accessDurationValue.trimmingCharacters(in: .whitespacesAndNewlines)), + value > 0 else { return nil } + return AccountAccessDuration(value: value, unit: accessDurationUnit) + } - let scopedToken = "\(proxySettings.adminKey):\(account.id.uuidString)" - let script = try createLaunchScript( - folder: folder, - baseURL: proxySettings.baseURL, - authToken: scopedToken, - defaultModel: account.claudeCodeDefaultModel - ) + var body: some View { + Form { + Section { + TextField("Label", text: $label, prompt: Text("e.g. Personal OpenRouter")) + Toggle("Enabled", isOn: $isEnabled) + } + Section("Access") { + Toggle("Track access date", isOn: $hasAccessGainedAt) + if hasAccessGainedAt { + DatePicker( + "Gained access", + selection: $accessGainedAt, + displayedComponents: .date + ) + } + Toggle("Track account period", isOn: $hasAccessDuration) + if hasAccessDuration { + HStack { + TextField("Period", text: $accessDurationValue) + .frame(width: 72) + Picker("Unit", selection: $accessDurationUnit) { + ForEach(AccountAccessDurationUnit.allCases) { unit in + Text(unit.displayName(for: durationValueForLabel)).tag(unit) + } + } + } + } + } + + Section { + if canEditAPIProfile { + Picker("Provider", selection: $provider) { + ForEach(apiKeyProviders) { provider in + Text(provider.displayName).tag(provider) + } + } + SecureField("New API Key", text: $apiKey, prompt: Text("Unchanged")) + TextField( + "Custom Endpoint", + text: $customEndpoint, + prompt: Text( + provider.requiresCustomEndpoint + ? "https://api.example.com/v1" + : "Optional override" + ) + ) + TextField("Default Model", text: $defaultModel, prompt: Text(provider.defaultModel ?? "Optional")) + } else { + LabeledContent("Provider", value: provider.displayName) + if let defaultModel = account.defaultModel { + LabeledContent("Default Model", value: defaultModel) + } + } + } + + if provider.isClaudeCodeCompatible { + Section("Claude Code") { + ClaudeCodeModelPicker( + title: "Opus", + selection: $claudeOpusModel, + options: claudeCodeModelOptions + ) + ClaudeCodeModelPicker( + title: "Sonnet", + selection: $claudeSonnetModel, + options: claudeCodeModelOptions + ) + ClaudeCodeModelPicker( + title: "Haiku", + selection: $claudeHaikuModel, + options: claudeCodeModelOptions + ) + } + } + + if let errorMessage { + Text(errorMessage) + .foregroundStyle(.red) + .font(.caption) + } + } + .formStyle(.grouped) + .padding() + .frame(width: 460, height: canEditAPIProfile ? 680 : 500) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { save() } + .disabled(!canSave) + } + } + .navigationTitle("Edit Account") + } + + private func save() { do { - try runInTerminal(script) + try store.updateProfile( + accountID: account.id, + label: label, + provider: provider, + customEndpoint: customEndpoint, + defaultModel: defaultModel, + accessGainedAt: hasAccessGainedAt ? accessGainedAt : nil, + accessDuration: parsedAccessDuration, + isEnabled: isEnabled, + apiKey: canEditAPIProfile ? apiKey : nil, + claudeCodeModelOverrides: provider.isClaudeCodeCompatible + ? ClaudeCodeModelOverrides.normalized( + opus: claudeOpusModel, + sonnet: claudeSonnetModel, + haiku: claudeHaikuModel + ) + : nil + ) + dismiss() } catch { - try? FileManager.default.removeItem(at: script) - throw error + errorMessage = "Could not save: \(error.localizedDescription)" } } - @MainActor - private static func chooseFolder() -> URL? { - let panel = NSOpenPanel() - panel.title = "Open Claude Code" - panel.message = "Choose a folder for Claude Code." - panel.prompt = "Open Claude Code" - panel.canChooseFiles = false - panel.canChooseDirectories = true - panel.allowsMultipleSelection = false - panel.canCreateDirectories = true - return panel.runModal() == .OK ? panel.url : nil + private var durationValueForLabel: Int { + Int(accessDurationValue.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 2 } - private static func createLaunchScript( - folder: URL, - baseURL: String, - authToken: String, - defaultModel: String? - ) throws -> URL { - let script = FileManager.default.temporaryDirectory - .appendingPathComponent("coderswitch-claude-\(UUID().uuidString).zsh") - let modelEnvironment = defaultModel.map { model in - """ - export ANTHROPIC_MODEL=\(model.shellQuotedForTerminal) - export ANTHROPIC_DEFAULT_HAIKU_MODEL=\(model.shellQuotedForTerminal) - export ANTHROPIC_DEFAULT_SONNET_MODEL=\(model.shellQuotedForTerminal) - export ANTHROPIC_DEFAULT_OPUS_MODEL=\(model.shellQuotedForTerminal) - """ - } ?? """ - unset ANTHROPIC_MODEL - unset ANTHROPIC_DEFAULT_HAIKU_MODEL - unset ANTHROPIC_DEFAULT_SONNET_MODEL - unset ANTHROPIC_DEFAULT_OPUS_MODEL - """ - let contents = """ - #!/bin/zsh - unset HISTFILE - rm -f \(script.path.shellQuotedForTerminal) - cd \(folder.path.shellQuotedForTerminal) || exit 1 - unset ANTHROPIC_API_KEY - unset ANTHROPIC_MODEL - unset ANTHROPIC_DEFAULT_HAIKU_MODEL - unset ANTHROPIC_DEFAULT_SONNET_MODEL - unset ANTHROPIC_DEFAULT_OPUS_MODEL - export ANTHROPIC_BASE_URL=\(baseURL.shellQuotedForTerminal) - export ANTHROPIC_AUTH_TOKEN=\(authToken.shellQuotedForTerminal) - \(modelEnvironment) - exec claude --dangerously-skip-permissions - """ - try contents.write(to: script, atomically: true, encoding: .utf8) - try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: script.path) - return script - } + private var claudeCodeModelOptions: [ProviderModel] { + var seen = Set() + var models: [ProviderModel] = [] + + func append(_ model: ProviderModel) { + let id = model.id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, seen.insert(id).inserted else { return } + models.append(model) + } + + if provider == account.provider { + for model in account.modelCatalog { + append(model) + } + } else { + for model in provider.bundledModels { + append(model) + } + } - private static func runInTerminal(_ script: URL) throws { - let appleScript = """ - tell application "Terminal" - activate - do script \(script.path.shellQuotedForTerminal.appleScriptLiteral) - end tell - """ - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") - process.arguments = ["-e", appleScript] - try process.run() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - throw ClaudeCodeLaunchError.terminalLaunchFailed + if let defaultModel = defaultModel.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty { + append(ProviderModel(id: defaultModel, name: defaultModel, ownedBy: provider.displayName)) } + + return models } } -private extension String { - var shellQuotedForTerminal: String { - "'\(replacingOccurrences(of: "'", with: "'\\''"))'" - } +private struct ClaudeCodeModelPicker: View { + let title: String + @Binding var selection: String + let options: [ProviderModel] - var appleScriptLiteral: String { - "\"\(replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\"" + var body: some View { + if options.isEmpty { + TextField(title, text: $selection, prompt: Text("Account default")) + } else { + Picker(title, selection: $selection) { + Text("Account default").tag("") + ForEach(options) { model in + Text(model.displayName).tag(model.id) + } + } + } } } @@ -692,3 +813,7 @@ private struct AccountLimitBar: View { } } } + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } +} diff --git a/CoderSwitch/Views/AddAccountView.swift b/CoderSwitch/Views/AddAccountView.swift index 52c07b9..a088904 100644 --- a/CoderSwitch/Views/AddAccountView.swift +++ b/CoderSwitch/Views/AddAccountView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore struct AddAccountView: View { @Environment(AccountStore.self) private var store @@ -8,18 +9,31 @@ struct AddAccountView: View { @State private var label: String = "" @State private var apiKey: String = "" @State private var customEndpoint: String = "" + @State private var hasAccessGainedAt = false + @State private var accessGainedAt = Date() + @State private var hasAccessDuration = false + @State private var accessDurationValue = "1" + @State private var accessDurationUnit: AccountAccessDurationUnit = .month @State private var errorMessage: String? private let providers = Provider.allCases.filter(\.canAddWithAPIKey) private var canSave: Bool { guard !label.trimmingCharacters(in: .whitespaces).isEmpty else { return false } guard !apiKey.trimmingCharacters(in: .whitespaces).isEmpty else { return false } + if hasAccessDuration && parsedAccessDuration == nil { return false } if provider.requiresCustomEndpoint { return !customEndpoint.trimmingCharacters(in: .whitespaces).isEmpty } return true } + private var parsedAccessDuration: AccountAccessDuration? { + guard hasAccessDuration else { return nil } + guard let value = Int(accessDurationValue.trimmingCharacters(in: .whitespacesAndNewlines)), + value > 0 else { return nil } + return AccountAccessDuration(value: value, unit: accessDurationUnit) + } + var body: some View { Form { Section { @@ -41,6 +55,29 @@ struct AddAccountView: View { ) } + Section("Access") { + Toggle("Track access date", isOn: $hasAccessGainedAt) + if hasAccessGainedAt { + DatePicker( + "Gained access", + selection: $accessGainedAt, + displayedComponents: .date + ) + } + Toggle("Track account period", isOn: $hasAccessDuration) + if hasAccessDuration { + HStack { + TextField("Period", text: $accessDurationValue) + .frame(width: 72) + Picker("Unit", selection: $accessDurationUnit) { + ForEach(AccountAccessDurationUnit.allCases) { unit in + Text(unit.displayName(for: durationValueForLabel)).tag(unit) + } + } + } + } + } + if let errorMessage { Text(errorMessage) .foregroundStyle(.red) @@ -49,7 +86,7 @@ struct AddAccountView: View { } .formStyle(.grouped) .padding() - .frame(width: 420, height: 320) + .frame(width: 420, height: 460) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } @@ -66,7 +103,11 @@ struct AddAccountView: View { let account = Account( label: label.trimmingCharacters(in: .whitespaces), provider: provider, - customEndpoint: customEndpoint.trimmingCharacters(in: .whitespaces) + customEndpoint: customEndpoint.trimmingCharacters(in: .whitespaces), + defaultModel: provider.defaultModel, + availableModels: provider.bundledModels.nonEmpty, + accessGainedAt: hasAccessGainedAt ? accessGainedAt : nil, + accessDuration: parsedAccessDuration ) do { try store.add(account, apiKey: apiKey.trimmingCharacters(in: .whitespaces)) @@ -75,4 +116,12 @@ struct AddAccountView: View { errorMessage = "Could not save: \(error.localizedDescription)" } } + + private var durationValueForLabel: Int { + Int(accessDurationValue.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 2 + } +} + +private extension Array { + var nonEmpty: [Element]? { isEmpty ? nil : self } } diff --git a/CoderSwitch/Views/CircuitBreakerSheet.swift b/CoderSwitch/Views/CircuitBreakerSheet.swift index 16c965f..0f28ac3 100644 --- a/CoderSwitch/Views/CircuitBreakerSheet.swift +++ b/CoderSwitch/Views/CircuitBreakerSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore struct CircuitBreakerSheet: View { @Environment(AccountStore.self) private var store diff --git a/CoderSwitch/Views/ConfigBackupTab.swift b/CoderSwitch/Views/ConfigBackupTab.swift index 9feb3a6..a4da2c7 100644 --- a/CoderSwitch/Views/ConfigBackupTab.swift +++ b/CoderSwitch/Views/ConfigBackupTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore import AppKit import UniformTypeIdentifiers diff --git a/CoderSwitch/Views/OAuthAccountsTab.swift b/CoderSwitch/Views/OAuthAccountsTab.swift index 5f198d4..8c1aee0 100644 --- a/CoderSwitch/Views/OAuthAccountsTab.swift +++ b/CoderSwitch/Views/OAuthAccountsTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore import UniformTypeIdentifiers struct OAuthAccountsTab: View { diff --git a/CoderSwitch/Views/OAuthFlowView.swift b/CoderSwitch/Views/OAuthFlowView.swift index ec31d6a..ccf466c 100644 --- a/CoderSwitch/Views/OAuthFlowView.swift +++ b/CoderSwitch/Views/OAuthFlowView.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore struct OAuthFlowView: View { let provider: OAuthProvider diff --git a/CoderSwitch/Views/ProxyTab.swift b/CoderSwitch/Views/ProxyTab.swift index 5a06c29..0fd3941 100644 --- a/CoderSwitch/Views/ProxyTab.swift +++ b/CoderSwitch/Views/ProxyTab.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore struct ProxyTab: View { @Environment(ProxySettings.self) private var settings @@ -104,12 +105,12 @@ struct ProxyTab: View { Section("Usage") { Text("Point Claude Code at this proxy:") .font(.caption) - Text("ANTHROPIC_BASE_URL=\(settings.baseURL)") + Text("ANTHROPIC_BASE_URL=\(settings.baseURL)/claude") .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) Text("Point Codex / OpenAI clients at this proxy:") .font(.caption) - Text("OPENAI_BASE_URL=\(settings.baseURL)/v1") + Text("OPENAI_BASE_URL=\(settings.baseURL)/codex/v1") .font(.system(.caption, design: .monospaced)) .textSelection(.enabled) } @@ -206,6 +207,9 @@ private struct RequestLogRow: View { Text(log.method) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) + Text(log.codingTool.displayName) + .font(.caption2) + .foregroundStyle(.secondary) Text(log.path) .font(.caption) .lineLimit(1) @@ -224,7 +228,7 @@ private struct RequestLogRow: View { .lineLimit(1) Spacer() if !log.usage.isEmpty { - Text("\(formatLogTokens(log.usage.totalTokens)) tokens") + Text("\(formatLogTokens(log.usage.processedTokens)) tokens") .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) } diff --git a/CoderSwitch/Views/SettingsWindow.swift b/CoderSwitch/Views/SettingsWindow.swift index 412ab7e..9db0574 100644 --- a/CoderSwitch/Views/SettingsWindow.swift +++ b/CoderSwitch/Views/SettingsWindow.swift @@ -1,4 +1,5 @@ import SwiftUI +import CoderSwitchCore struct SettingsWindow: View { @State private var selectedTab: Tab = .accounts diff --git a/CoderSwitch/Views/StatusPopover.swift b/CoderSwitch/Views/StatusPopover.swift index 0a0e444..092a460 100644 --- a/CoderSwitch/Views/StatusPopover.swift +++ b/CoderSwitch/Views/StatusPopover.swift @@ -1,12 +1,14 @@ import SwiftUI +import CoderSwitchCore struct StatusPopover: View { + var openSettings: (() -> Void)? + @Environment(AccountStore.self) private var store @Environment(OAuthStore.self) private var oauthStore @Environment(ProxySettings.self) private var settings @Environment(ProxyManager.self) private var proxy @Environment(QuotaPoller.self) private var poller - @Environment(\.openWindow) private var openWindow var body: some View { VStack(alignment: .leading, spacing: 12) { @@ -52,11 +54,12 @@ struct StatusPopover: View { Divider() Button("Settings…") { - openWindow(id: "settings") + openSettings?() } .keyboardShortcut(",") Button("Quit") { + AppTerminationController.shared.allowTermination() NSApplication.shared.terminate(nil) } .keyboardShortcut("q") @@ -70,6 +73,7 @@ private struct OAuthQuickSwitch: View { @Environment(AccountStore.self) private var accountStore @Environment(OAuthStore.self) private var oauthStore @Environment(ProxySettings.self) private var settings + @Environment(ProxyManager.self) private var proxyManager @State private var isExpanded = false @State private var statusMessage: String? @@ -109,7 +113,7 @@ private struct OAuthQuickSwitch: View { } } .buttonStyle(.borderless) - .disabled(!account.isEnabled || settings.adminKey.isEmpty) + .disabled(!account.isEnabled) } Button { @@ -159,15 +163,19 @@ private struct OAuthQuickSwitch: View { } private func switchClaudeCode(to account: Account) { + statusMessage = nil do { - try ClaudeCodeConfigSwitcher().switchToCoderSwitch( + try ClaudeCodeLauncher.open( account: account, - proxyBaseURL: settings.baseURL, - adminKey: settings.adminKey + apiKey: accountStore.apiKey(for: account), + proxySettings: settings, + proxyManager: proxyManager ) - statusMessage = "Claude Code switched to \(account.label)." + statusMessage = "Claude Code opened with \(account.label)." + } catch ClaudeCodeLaunchError.cancelled { + return } catch { - statusMessage = "Claude Code switch failed: \(error.localizedDescription)" + statusMessage = "Claude Code launch failed: \(error.localizedDescription)" } } @@ -213,7 +221,7 @@ private struct AccountUsageRow: View { .font(.caption2) .foregroundStyle(.orange) } else if account.usageLimits.isEmpty { - if account.provider.quotaCheck == nil && account.provider.isProxyRoutable { + if !account.provider.supportsUsagePolling && account.provider.isProxyRoutable { Text("No quota endpoint") .font(.caption2) .foregroundStyle(.tertiary) @@ -236,10 +244,10 @@ private struct AccountUsageRow: View { UsageLimitBar(limit: limit) } } - let today = account.tokenUsageTotal(period: .day) + let today = account.tokenUsageTotal(period: .today) let allTime = account.tokenUsageTotal(period: .allTime) if !allTime.isEmpty { - Text("Tokens today \(formatTokens(today.totalTokens)) • all \(formatTokens(allTime.totalTokens))") + Text("Tokens today \(formatTokens(today.processedTokens)) • all \(formatTokens(allTime.processedTokens))") .font(.caption2) .foregroundStyle(.tertiary) } @@ -268,6 +276,11 @@ private struct UsageLimitBar: View { .foregroundStyle(.secondary) } } + if let resetAt = limit.resetAt { + Text("Resets \(resetAt, style: .relative)") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } if let fraction = limit.fraction { ProgressView(value: fraction) .progressViewStyle(.linear) diff --git a/CoderSwitch/Views/UsageTab.swift b/CoderSwitch/Views/UsageTab.swift index 2612d59..464d9c7 100644 --- a/CoderSwitch/Views/UsageTab.swift +++ b/CoderSwitch/Views/UsageTab.swift @@ -1,10 +1,11 @@ import SwiftUI +import CoderSwitchCore import Charts struct UsageTab: View { - @Environment(AccountStore.self) private var store - @State private var period: TokenUsagePeriod = .week - @State private var expandedAccountIDs: Set = [] + @Environment(AccountStore.self) private var accountStore + @Environment(RequestLogStore.self) private var requestLogStore + @State private var period: TokenUsagePeriod = .today var body: some View { ScrollView { @@ -16,46 +17,75 @@ struct UsageTab: View { } .pickerStyle(.segmented) - TotalsCard(totals: store.tokenUsageTotal(period: period), period: period) + TotalsCard(totals: requestLogStore.tokenUsageTotal(period: period), period: period) - DailyChartCard(series: store.tokenUsageDailySeries(period: period)) + DailyChartCard(series: requestLogStore.tokenUsageDailySeries(period: period)) - ByAccountCard( - rows: store.tokenUsageByAccount(period: period), - period: period, - expandedIDs: $expandedAccountIDs + UsageRowsCard( + title: "By coding tool", + emptyMessage: "No tool usage recorded for this period.", + rows: requestLogStore.tokenUsageTotalsByCodingTool(period: period) ) - ByProviderCard(rows: store.tokenUsageTotalsByProvider(period: period)) + UsageRowsCard( + title: "By provider", + emptyMessage: "No provider usage recorded for this period.", + rows: requestLogStore.tokenUsageTotalsByProvider(period: period) + ) + + UsageRowsCard( + title: "By account", + emptyMessage: "No account usage recorded for this period.", + rows: accountRows() + ) + + UsageRowsCard( + title: "By model", + emptyMessage: "No model usage recorded for this period.", + rows: requestLogStore.tokenUsageTotalsByModel(period: period) + ) } .padding() } } + + private func accountRows() -> [TokenUsageGroupTotal] { + let accountsByID = Dictionary(uniqueKeysWithValues: accountStore.accounts.map { ($0.id, $0) }) + return requestLogStore.tokenUsageTotalsByAccount(period: period).map { row in + let account = accountsByID[row.accountID] + let label = account.map { "\($0.label) · \($0.provider.displayName)" } ?? row.accountID.uuidString + return TokenUsageGroupTotal(id: row.accountID.uuidString, label: label, totals: row.totals) + } + } } private struct TotalsCard: View { let totals: TokenUsageDelta let period: TokenUsagePeriod + private let columns = [ + GridItem(.adaptive(minimum: 112), spacing: 12, alignment: .leading) + ] + var body: some View { GroupBox { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { HStack { Text(period.displayName) .font(.headline) Spacer() - Text(formatTokens(totals.totalTokens)) + Text(formatTokens(totals.processedTokens)) .font(.system(.title2, design: .monospaced)) } - HStack(spacing: 16) { + + LazyVGrid(columns: columns, alignment: .leading, spacing: 10) { StatBlock(label: "Requests", value: "\(totals.requests)") StatBlock(label: "Input", value: formatTokens(totals.inputTokens)) StatBlock(label: "Output", value: formatTokens(totals.outputTokens)) - if totals.cacheReadTokens > 0 { - StatBlock(label: "Cache R", value: formatTokens(totals.cacheReadTokens)) - } - if totals.cacheWriteTokens > 0 { - StatBlock(label: "Cache W", value: formatTokens(totals.cacheWriteTokens)) + StatBlock(label: "Cache write", value: formatTokens(totals.cacheWriteTokens)) + StatBlock(label: "Cache read", value: formatTokens(totals.cacheReadTokens)) + if totals.uncategorizedTokens > 0 { + StatBlock(label: "Other", value: formatTokens(totals.uncategorizedTokens)) } } } @@ -74,6 +104,8 @@ private struct StatBlock: View { .foregroundStyle(.secondary) Text(value) .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.85) } } } @@ -101,6 +133,16 @@ private struct DailyChartCard: View { y: .value("Output", entry.totals.outputTokens) ) .foregroundStyle(by: .value("Kind", "Output")) + BarMark( + x: .value("Day", entry.day), + y: .value("Cache write", entry.totals.cacheWriteTokens) + ) + .foregroundStyle(by: .value("Kind", "Cache write")) + BarMark( + x: .value("Day", entry.day), + y: .value("Cache read", entry.totals.cacheReadTokens) + ) + .foregroundStyle(by: .value("Kind", "Cache read")) } } .chartLegend(position: .bottom) @@ -110,29 +152,23 @@ private struct DailyChartCard: View { } } -private struct ByAccountCard: View { - let rows: [(account: Account, totals: TokenUsageDelta)] - let period: TokenUsagePeriod - @Binding var expandedIDs: Set +private struct UsageRowsCard: View { + let title: String + let emptyMessage: String + let rows: [TokenUsageGroupTotal] var body: some View { - GroupBox("By account") { + GroupBox(title) { if rows.isEmpty { - Text("No account usage recorded for this period.") + Text(emptyMessage) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 12) } else { VStack(alignment: .leading, spacing: 0) { - ForEach(rows, id: \.account.id) { entry in - AccountUsageRow( - account: entry.account, - totals: entry.totals, - period: period, - expanded: expandedIDs.contains(entry.account.id), - onToggle: { toggle(entry.account.id) } - ) - if entry.account.id != rows.last?.account.id { + ForEach(rows) { row in + UsageBreakdownRow(row: row) + if row.id != rows.last?.id { Divider() } } @@ -140,109 +176,58 @@ private struct ByAccountCard: View { } } } - - private func toggle(_ id: UUID) { - if expandedIDs.contains(id) { - expandedIDs.remove(id) - } else { - expandedIDs.insert(id) - } - } } -private struct AccountUsageRow: View { - let account: Account - let totals: TokenUsageDelta - let period: TokenUsagePeriod - let expanded: Bool - let onToggle: () -> Void +private struct UsageBreakdownRow: View { + let row: TokenUsageGroupTotal var body: some View { - VStack(alignment: .leading, spacing: 4) { - Button(action: onToggle) { - HStack { - Image(systemName: expanded ? "chevron.down" : "chevron.right") - .font(.caption) - .foregroundStyle(.secondary) - VStack(alignment: .leading) { - Text(account.label) - Text(account.provider.displayName) - .font(.caption2) - .foregroundStyle(.secondary) - } - Spacer() - Text("\(totals.requests) req") - .font(.caption) - .foregroundStyle(.secondary) - Text(formatTokens(totals.totalTokens)) - .font(.system(.body, design: .monospaced)) - } + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline) { + Text(row.label) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text("\(row.totals.requests) req") + .font(.caption) + .foregroundStyle(.secondary) + Text(formatTokens(row.totals.processedTokens)) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + .minimumScaleFactor(0.85) } - .buttonStyle(.plain) - .contentShape(Rectangle()) - - if expanded { - let modelTotals = account.tokenUsageTotalsByModel(period: period) - if modelTotals.isEmpty { - Text("No model breakdown available.") - .font(.caption) - .foregroundStyle(.tertiary) - .padding(.leading, 24) - } else { - ForEach(modelTotals) { item in - HStack { - Text(item.label) - .font(.caption) - Spacer() - Text("in \(formatTokens(item.totals.inputTokens))") - .font(.caption2) - .foregroundStyle(.secondary) - Text("out \(formatTokens(item.totals.outputTokens))") - .font(.caption2) - .foregroundStyle(.secondary) - Text(formatTokens(item.totals.totalTokens)) - .font(.system(.caption, design: .monospaced)) - } - .padding(.leading, 24) - } + + HStack(spacing: 10) { + MiniStat(label: "in", value: row.totals.inputTokens) + MiniStat(label: "out", value: row.totals.outputTokens) + MiniStat(label: "cw", value: row.totals.cacheWriteTokens) + MiniStat(label: "cr", value: row.totals.cacheReadTokens) + if row.totals.uncategorizedTokens > 0 { + MiniStat(label: "other", value: row.totals.uncategorizedTokens) } } } - .padding(.vertical, 6) + .padding(.vertical, 7) } } -private struct ByProviderCard: View { - let rows: [TokenUsageGroupTotal] +private struct MiniStat: View { + let label: String + let value: Int var body: some View { - GroupBox("By provider") { - if rows.isEmpty { - Text("No provider usage recorded.") - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 12) - } else { - VStack(alignment: .leading, spacing: 6) { - ForEach(rows) { row in - HStack { - Text(row.label) - Spacer() - Text("\(row.totals.requests) req") - .font(.caption) - .foregroundStyle(.secondary) - Text(formatTokens(row.totals.totalTokens)) - .font(.system(.body, design: .monospaced)) - } - } - } - } - } + Text("\(label) \(formatTokens(value))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) } } private func formatTokens(_ value: Int) -> String { switch value { + case 1_000_000_000...: + String(format: "%.1fB", Double(value) / 1_000_000_000) case 1_000_000...: String(format: "%.1fM", Double(value) / 1_000_000) case 1_000...: diff --git a/CoderSwitchTests/ClaudeCodeConfigSwitcherTests.swift b/CoderSwitchTests/ClaudeCodeConfigSwitcherTests.swift deleted file mode 100644 index 2aedd73..0000000 --- a/CoderSwitchTests/ClaudeCodeConfigSwitcherTests.swift +++ /dev/null @@ -1,115 +0,0 @@ -import XCTest -@testable import CoderSwitch - -final class ClaudeCodeConfigSwitcherTests: XCTestCase { - func testSwitchToCoderSwitchPreservesUnrelatedSettingsAndWritesClaudeEnv() throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("ClaudeCodeConfigSwitcherTests-\(UUID().uuidString)", isDirectory: true) - let settingsURL = dir.appendingPathComponent("settings.json") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try Data(""" - { - "permissions": { "allow": ["Bash(ls)"] }, - "env": { - "KEEP_ME": "yes", - "ANTHROPIC_API_KEY": "old", - "ANTHROPIC_MODEL": "old-model" - } - } - """.utf8).write(to: settingsURL) - - let account = Account( - id: UUID(uuidString: "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE")!, - label: "Work", - provider: .anthropicCompatible, - customEndpoint: "https://upstream.example", - defaultModel: "claude-work" - ) - try ClaudeCodeConfigSwitcher(settingsURL: settingsURL).switchToCoderSwitch( - account: account, - proxyBaseURL: "http://localhost:8484/", - adminKey: "cs-test" - ) - - let settings = try readSettings(settingsURL) - let env = try XCTUnwrap(settings["env"] as? [String: Any]) - XCTAssertNotNil(settings["permissions"]) - XCTAssertEqual(env["KEEP_ME"] as? String, "yes") - XCTAssertNil(env["ANTHROPIC_API_KEY"]) - XCTAssertEqual(env["ANTHROPIC_MODEL"] as? String, "claude-work") - XCTAssertEqual(env["ANTHROPIC_BASE_URL"] as? String, "http://localhost:8484") - XCTAssertEqual(env["ANTHROPIC_AUTH_TOKEN"] as? String, "cs-test:AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE") - XCTAssertEqual(env["ANTHROPIC_DEFAULT_SONNET_MODEL"] as? String, "claude-work") - XCTAssertTrue(FileManager.default.fileExists(atPath: settingsURL.appendingPathExtension("coderswitch.bak").path)) - } - - func testSwitchToCoderSwitchRemovesStaleManagedModelDefaultsWhenAccountHasNoDefaultModel() throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("ClaudeCodeConfigSwitcherTests-\(UUID().uuidString)", isDirectory: true) - let settingsURL = dir.appendingPathComponent("settings.json") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try Data(""" - { - "env": { - "ANTHROPIC_MODEL": "old-model", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "old-model", - "KEEP_ME": "yes" - } - } - """.utf8).write(to: settingsURL) - - let account = Account(label: "Work", provider: .anthropicCompatible, customEndpoint: "https://upstream.example") - try ClaudeCodeConfigSwitcher(settingsURL: settingsURL).switchToCoderSwitch( - account: account, - proxyBaseURL: "http://localhost:8484", - adminKey: "cs-test" - ) - - let settings = try readSettings(settingsURL) - let env = try XCTUnwrap(settings["env"] as? [String: Any]) - XCTAssertNil(env["ANTHROPIC_MODEL"]) - XCTAssertNil(env["ANTHROPIC_DEFAULT_SONNET_MODEL"]) - XCTAssertEqual(env["KEEP_ME"] as? String, "yes") - XCTAssertEqual(env["ANTHROPIC_BASE_URL"] as? String, "http://localhost:8484") - } - - func testRestoreOfficialClaudeRemovesOnlyManagedEnvKeys() throws { - let dir = FileManager.default.temporaryDirectory - .appendingPathComponent("ClaudeCodeConfigSwitcherTests-\(UUID().uuidString)", isDirectory: true) - let settingsURL = dir.appendingPathComponent("settings.json") - try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - try Data(""" - { - "env": { - "KEEP_ME": "yes", - "ANTHROPIC_BASE_URL": "http://localhost:8484", - "ANTHROPIC_AUTH_TOKEN": "cs-test:account", - "ANTHROPIC_MODEL": "old-model" - } - } - """.utf8).write(to: settingsURL) - - try ClaudeCodeConfigSwitcher(settingsURL: settingsURL).restoreOfficialClaude() - - let settings = try readSettings(settingsURL) - let env = try XCTUnwrap(settings["env"] as? [String: Any]) - XCTAssertEqual(env.count, 1) - XCTAssertEqual(env["KEEP_ME"] as? String, "yes") - } - - func testClaudeCodeDefaultModelPrefersConfiguredDefault() { - let account = Account( - label: "custom anthropic", - provider: .anthropicCompatible, - customEndpoint: "https://api.example.com", - defaultModel: "claude-sonnet-4" - ) - - XCTAssertEqual(account.claudeCodeDefaultModel, "claude-sonnet-4") - } - - private func readSettings(_ url: URL) throws -> [String: Any] { - let data = try Data(contentsOf: url) - return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) - } -} diff --git a/CoderSwitchTests/ModelRouterTests.swift b/CoderSwitchTests/ModelRouterTests.swift deleted file mode 100644 index 61a6783..0000000 --- a/CoderSwitchTests/ModelRouterTests.swift +++ /dev/null @@ -1,90 +0,0 @@ -import XCTest -@testable import CoderSwitch - -final class ModelRouterTests: XCTestCase { - func testRoutesProviderAndLabelPrefix() { - let account = Account(label: "Work", provider: .openRouter) - let router = ModelRouter(accounts: [account]) - - let resolved = router.resolve( - model: "openrouter:work/anthropic/claude-3.5-sonnet", - compatibility: .openAI - ) - - XCTAssertEqual(resolved?.account.id, account.id) - XCTAssertEqual(resolved?.upstreamModel, "anthropic/claude-3.5-sonnet") - } - - func testPreferredAccountOverridesUnprefixedModel() { - let first = Account(label: "Default", provider: .openAICompatible, customEndpoint: "https://one.example/v1") - let second = Account(label: "Preferred", provider: .openAICompatible, customEndpoint: "https://two.example/v1") - let router = ModelRouter(accounts: [first, second]) - - let resolved = router.resolve( - model: "gpt-5-mini", - compatibility: .openAI, - preferredAccountID: second.id - ) - - XCTAssertEqual(resolved?.account.id, second.id) - XCTAssertEqual(resolved?.upstreamModel, "gpt-5-mini") - } - - func testPreferredAnthropicAccountAliasUsesDefaultModel() { - var first = Account(label: "Default", provider: .anthropicCompatible, customEndpoint: "https://one.example") - first.defaultModel = "claude-3-5-haiku" - var second = Account(label: "Work", provider: .anthropicCompatible, customEndpoint: "https://two.example") - second.defaultModel = "claude-3-5-sonnet" - let router = ModelRouter(accounts: [first, second]) - - let resolved = router.resolve( - model: "anthropic:work", - compatibility: .anthropic, - preferredAccountID: second.id - ) - - XCTAssertEqual(resolved?.account.id, second.id) - XCTAssertEqual(resolved?.upstreamModel, "claude-3-5-sonnet") - } - - func testDisabledAccountsAreSkipped() { - var disabled = Account(label: "Disabled", provider: .openAICompatible, customEndpoint: "https://off.example/v1") - disabled.isEnabled = false - let enabled = Account(label: "Enabled", provider: .openAICompatible, customEndpoint: "https://on.example/v1") - let router = ModelRouter(accounts: [disabled, enabled]) - - let resolved = router.resolve(model: "gpt-5-mini", compatibility: .openAI) - - XCTAssertEqual(resolved?.account.id, enabled.id) - } - - func testPreferredDisabledAccountDoesNotRoute() { - var account = Account(label: "Disabled", provider: .openAICompatible, customEndpoint: "https://off.example/v1") - account.isEnabled = false - let router = ModelRouter(accounts: [account]) - - let resolved = router.resolve( - model: "gpt-5-mini", - compatibility: .openAI, - preferredAccountID: account.id - ) - - XCTAssertNil(resolved) - } - - func testLegacyProxyProviderAccountDecodesAsCustomOpenAIEndpoint() throws { - XCTAssertFalse(Provider.allCases.contains { $0.rawValue == "ikunCode" }) - XCTAssertFalse(Provider.allCases.contains { $0.rawValue == "fishXCode" }) - - let account = Account(label: "Legacy", provider: .openRouter) - let encoder = JSONEncoder() - let data = try encoder.encode(account) - let json = try XCTUnwrap(String(data: data, encoding: .utf8)) - let legacyJson = json.replacingOccurrences(of: "\"provider\":\"openRouter\"", with: "\"provider\":\"ikunCode\"") - - let decoded = try JSONDecoder().decode(Account.self, from: XCTUnwrap(legacyJson.data(using: .utf8))) - - XCTAssertEqual(decoded.provider, .openAICompatible) - XCTAssertEqual(decoded.customEndpoint, "https://api.ikuncode.cc/v1") - } -} diff --git a/CoderSwitchTests/QuotaCheckTests.swift b/CoderSwitchTests/QuotaCheckTests.swift deleted file mode 100644 index 601774c..0000000 --- a/CoderSwitchTests/QuotaCheckTests.swift +++ /dev/null @@ -1,310 +0,0 @@ -import XCTest -@testable import CoderSwitch - -final class QuotaCheckTests: XCTestCase { - func testOpenRouterCreditsParseAsPayAsYouGoBalance() throws { - let data = Data(""" - { - "data": { - "total_credits": 20.0, - "total_usage": 7.5 - } - } - """.utf8) - - let check = try XCTUnwrap(Provider.openRouter.quotaCheck) - let limits = try check.parse(data, 200) - - XCTAssertEqual(limits.count, 1) - XCTAssertEqual(limits[0].name, "Balance") - XCTAssertEqual(limits[0].used, 12.5) - XCTAssertTrue(limits[0].isPayAsYouGo) - } - - func testMiniMaxRemainsParsesRemainingRequestWindows() throws { - let data = Data(""" - { - "base_resp": { - "status_code": 0, - "status_msg": "ok" - }, - "model_remains": [ - { - "model_name": "MiniMax-M2.7", - "current_interval_total_count": 100, - "current_interval_remaining_count": 25, - "weekly_total_count": 500, - "weekly_remaining_count": 200 - } - ] - } - """.utf8) - - let check = try XCTUnwrap(Provider.miniMax.quotaCheck) - let limits = try check.parse(data, 200) - - XCTAssertEqual(limits.map(\.name), ["MiniMax-M2.7", "MiniMax-M2.7 weekly"]) - XCTAssertEqual(limits[0].used, 75) - XCTAssertEqual(limits[0].limit, 100) - XCTAssertEqual(limits[1].used, 300) - XCTAssertEqual(limits[1].limit, 500) - } - - func testMiniMaxUsageCountIsRemainingRequests() throws { - let data = Data(""" - { - "base_resp": { - "status_code": 0, - "status_msg": "ok" - }, - "model_remains": [ - { - "model_name": "MiniMax-M2.7", - "current_interval_total_count": 1500, - "current_interval_usage_count": 22 - } - ] - } - """.utf8) - - let check = try XCTUnwrap(Provider.miniMax.quotaCheck) - let limits = try check.parse(data, 200) - - XCTAssertEqual(limits.count, 1) - XCTAssertEqual(limits[0].used, 22) - XCTAssertEqual(limits[0].limit, 1500) - XCTAssertEqual(limits[0].fraction, 22.0 / 1500.0) - XCTAssertEqual(limits[0].valueKind, "minimaxRemainingNormalized") - } - - func testMiniMaxUsageCountTakesPriorityWhenOtherCountFieldsDisagree() throws { - let data = Data(""" - { - "base_resp": { - "status_code": 0, - "status_msg": "ok" - }, - "model_remains": [ - { - "model_name": "MiniMax-M*", - "current_interval_total_count": 1500, - "current_interval_remaining_count": 999, - "current_interval_usage_count": 22 - } - ] - } - """.utf8) - - let check = try XCTUnwrap(Provider.miniMax.quotaCheck) - let limits = try check.parse(data, 200) - - XCTAssertEqual(limits.count, 1) - XCTAssertEqual(limits[0].used, 22) - XCTAssertEqual(limits[0].limit, 1500) - XCTAssertEqual(limits[0].fraction, 22.0 / 1500.0) - } - - func testLegacyMiniMaxStoredRemainingCountMigratesToUsedCount() throws { - let data = Data(""" - { - "createdAt": 800946669.87835, - "id": "176D8D27-7C20-4682-A6C8-84D6F25729B7", - "isEnabled": true, - "label": "minimax", - "provider": "miniMax", - "usageLimits": [ - { - "isPayAsYouGo": false, - "limit": 1500, - "name": "MiniMax-M*", - "unit": " requests", - "used": 1478 - } - ] - } - """.utf8) - - let account = try JSONDecoder().decode(Account.self, from: data) - - XCTAssertEqual(account.usageLimits.count, 1) - XCTAssertEqual(account.usageLimits[0].used, 22) - XCTAssertEqual(account.usageLimits[0].limit, 1500) - XCTAssertEqual(account.usageLimits[0].fraction, 22.0 / 1500.0) - XCTAssertEqual(account.usageLimits[0].valueKind, "minimaxRemainingNormalized") - } - - func testMarkedLegacyMiniMaxRemainingCountMigratesToUsedCount() throws { - let data = Data(""" - { - "createdAt": 800946669.87835, - "id": "176D8D27-7C20-4682-A6C8-84D6F25729B7", - "isEnabled": true, - "label": "minimax", - "provider": "miniMax", - "usageLimits": [ - { - "isPayAsYouGo": false, - "limit": 1500, - "name": "MiniMax-M*", - "unit": " requests", - "used": 1478, - "valueKind": "used" - } - ] - } - """.utf8) - - let account = try JSONDecoder().decode(Account.self, from: data) - - XCTAssertEqual(account.usageLimits.count, 1) - XCTAssertEqual(account.usageLimits[0].used, 22) - XCTAssertEqual(account.usageLimits[0].limit, 1500) - XCTAssertEqual(account.usageLimits[0].fraction, 22.0 / 1500.0) - XCTAssertEqual(account.usageLimits[0].valueKind, "minimaxRemainingNormalized") - } - - func testMarkedMiniMaxUsedCountDoesNotMigrateAgain() throws { - let data = Data(""" - { - "createdAt": 800946669.87835, - "id": "176D8D27-7C20-4682-A6C8-84D6F25729B7", - "isEnabled": true, - "label": "minimax", - "provider": "miniMax", - "usageLimits": [ - { - "isPayAsYouGo": false, - "limit": 1500, - "name": "MiniMax-M*", - "unit": " requests", - "used": 22, - "valueKind": "minimaxRemainingNormalized" - } - ] - } - """.utf8) - - let account = try JSONDecoder().decode(Account.self, from: data) - - XCTAssertEqual(account.usageLimits.count, 1) - XCTAssertEqual(account.usageLimits[0].used, 22) - XCTAssertEqual(account.usageLimits[0].limit, 1500) - XCTAssertEqual(account.usageLimits[0].fraction, 22.0 / 1500.0) - } - - func testGoogleAntigravityRemainingZeroShowsFullUsageBar() throws { - let limit = UsageLimit( - name: "Gemini", - used: 0, - limit: 100, - unit: "%", - valueKind: "googleAntigravityRemainingPercent" - ) - - XCTAssertEqual(limit.fraction, 1) - XCTAssertEqual(limit.remainingDescription, "100% used") - } - - func testGoogleAntigravityRemainingPercentInvertsToConsumedFraction() throws { - let limit = UsageLimit( - name: "Gemini", - used: 35, - limit: 100, - unit: "%", - valueKind: "googleAntigravityRemainingPercent" - ) - - XCTAssertEqual(limit.fraction, 0.65) - XCTAssertEqual(limit.remainingDescription, "65% used") - } - - func testGoogleAntigravityFiltersPlaceholderAndTabModels() throws { - let data = Data(""" - { - "models": { - "tab_flash_lite_preview": { - "displayName": "MODEL_PLACEHOLDER_M19", - "quotaInfo": { - "remainingFraction": 1.0 - } - }, - "tab_jump_flash_lite_preview": { - "displayName": "MODEL_PLACEHOLDER_M28", - "quotaInfo": { - "remainingFraction": 1.0 - } - }, - "gemini-3-pro-high": { - "displayName": "Gemini 3 Pro (High)", - "quotaInfo": { - "remainingFraction": 0.35 - } - } - } - } - """.utf8) - - let usage = try JSONDecoder().decode(AntigravityFetchAvailableModelsResponse.self, from: data) - let limits = usage.usageLimits - - XCTAssertEqual(limits.map(\.name), ["Gemini 3 Pro (High)"]) - XCTAssertEqual(limits[0].storageID, "gemini-3-pro-high") - XCTAssertEqual(limits[0].used, 35) - } - - func testGoogleAntigravityKeepsModelIDWhenDisplayNameIsMissing() throws { - let data = Data(""" - { - "models": { - "gemini-3-flash": { - "quotaInfo": { - "remainingFraction": 0.8 - } - } - } - } - """.utf8) - - let usage = try JSONDecoder().decode(AntigravityFetchAvailableModelsResponse.self, from: data) - let limits = usage.usageLimits - - XCTAssertEqual(limits.map(\.name), ["gemini-3-flash"]) - XCTAssertEqual(limits[0].storageID, "gemini-3-flash") - } - - func testCodexUsageAllowsObjectRateLimitReachedType() throws { - let data = Data(""" - { - "plan_type": "plus", - "rate_limit_reached_type": { - "type": "rate_limit_reached", - "details": "default" - }, - "rate_limit": { - "primary_window": { - "used_percent": 100, - "limit_window_seconds": 18000, - "reset_at": 1779310701 - }, - "secondary_window": { - "used_percent": 16, - "limit_window_seconds": 604800, - "reset_at": 1779897501 - } - }, - "credits": { - "has_credits": false, - "unlimited": false, - "balance": "0" - } - } - """.utf8) - - let usage = try JSONDecoder().decode(CodexUsageResponse.self, from: data) - let limits = usage.usageLimits - - XCTAssertEqual(limits.map(\.name), ["5-hour limit", "Weekly limit"]) - XCTAssertEqual(limits[0].used, 100) - XCTAssertEqual(limits[1].used, 16) - } -} diff --git a/CoderSwitchTests/TokenUsageParserTests.swift b/CoderSwitchTests/TokenUsageParserTests.swift deleted file mode 100644 index 22e207b..0000000 --- a/CoderSwitchTests/TokenUsageParserTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -import XCTest -@testable import CoderSwitch - -final class TokenUsageParserTests: XCTestCase { - func testParsesResponsesUsageObject() throws { - let data = Data(""" - { - "usage": { - "input_tokens": 12, - "output_tokens": 7, - "total_tokens": 21, - "input_tokens_details": { - "cached_tokens": 3 - } - } - } - """.utf8) - - let usage = try XCTUnwrap(TokenUsageParser.parseJSONResponse(data)) - - XCTAssertEqual(usage.inputTokens, 12) - XCTAssertEqual(usage.outputTokens, 7) - XCTAssertEqual(usage.cacheReadTokens, 3) - XCTAssertEqual(usage.uncategorizedTokens, 2) - } - - func testParsesResponsesStreamingCompletedEvent() throws { - let event = """ - event: response.completed - data: {"type":"response.completed","response":{"usage":{"input_tokens":10,"output_tokens":4,"total_tokens":14}}} - - """ - - let usage = try XCTUnwrap(TokenUsageParser.parseSSEEvent(event)) - - XCTAssertEqual(usage.inputTokens, 10) - XCTAssertEqual(usage.outputTokens, 4) - XCTAssertEqual(usage.totalTokens, 14) - } - - func testParsesAnthropicMessageUsageEnvelope() throws { - let data = Data(""" - { - "message": { - "usage": { - "input_tokens": 8, - "output_tokens": 5, - "cache_creation_input_tokens": 2, - "cache_read_input_tokens": 1 - } - } - } - """.utf8) - - let usage = try XCTUnwrap(TokenUsageParser.parseJSONResponse(data)) - - XCTAssertEqual(usage.inputTokens, 8) - XCTAssertEqual(usage.outputTokens, 5) - XCTAssertEqual(usage.cacheWriteTokens, 2) - XCTAssertEqual(usage.cacheReadTokens, 1) - } -} diff --git a/README.md b/README.md index 0fa7522..b95f686 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ A local macOS menu bar app for managing AI subscriptions and API keys across mul - **Claude Code quick switch**: Point Claude Code at any Anthropic-compatible account from the menu bar - **Local proxy**: Single proxy URL for Claude Code (`ANTHROPIC_BASE_URL`) and OpenAI-compatible clients (`OPENAI_BASE_URL`) - **Responses API support**: Proxies OpenAI-compatible `/v1/responses` calls as well as chat completions -- **Usage tracking**: Tracks token usage (input, output, cache) by day/week/month/year +- **Usage tracking**: Tracks request-log token usage across coding tools for today, last 24h, last 7d, last 30d, YTD, and all time - **Request logs**: Shows recent proxy requests with status, latency, route, and token counts - **Quota monitoring**: Polls provider APIs for balance and request limits - **Secure storage**: API keys and OAuth tokens encrypted in local SQLite storage @@ -86,7 +86,10 @@ When using the proxy, specify models using the provider's routing slug: | OpenAI | OpenAI-compatible | `openai-direct:label/model` | `openai-direct:work/gpt-4o` | | Anthropic | Anthropic-compatible | `anthropic-direct:label/model` | `anthropic-direct:work/claude-sonnet-4` | | OpenRouter | OpenAI-compatible | `openrouter:label/model` | `openrouter:default/anthropic/claude-3-opus` | -| MiniMax | OpenAI-compatible | `minimax:label/model` | `minimax:default/MiniMax-M2.7` | +| MiniMax | Anthropic-compatible | `minimax:label/model` | `minimax:default/MiniMax-M2.7` | +| GLM Coding Plan (Z.AI) | Anthropic-compatible | `glm:label/model` | `glm:default/glm-4.7` | +| Kimi Coding Plan | Anthropic-compatible | `kimi:label/model` | `kimi:default/kimi-k2.5` | +| Qwen Coding Plan (Alibaba Cloud) | Anthropic-compatible | `qwen:label/model` | `qwen:default/qwen3-coder-plus` | | OpenAI-compatible (custom) | OpenAI-compatible | `openai:label/model` | `openai:work/gpt-4o` | | Anthropic-compatible (custom) | Anthropic-compatible | `anthropic:label/model` | `anthropic:work/claude-sonnet-4` | @@ -107,10 +110,13 @@ and visibility, but they are not routed through the local API proxy. ### Claude Code -Claude Code can use CoderSwitch through the local Anthropic-compatible proxy. -Add an `Anthropic` or `Anthropic-compatible (custom)` account, set its API key, -and optionally choose a default model in Settings. CoderSwitch uses that default -model when Claude Code asks for a generic Sonnet/Haiku/Opus model. +Claude Code can use CoderSwitch-managed Anthropic-compatible accounts directly, +or through the local Anthropic-compatible proxy for manual routing. Add an +`Anthropic`, `MiniMax`, `GLM Coding Plan`, `Kimi Coding Plan`, +`Qwen Coding Plan`, or `Anthropic-compatible (custom)` account, set its API key, +and optionally choose default Claude Code model mappings in Settings. Built-in +coding-plan providers include their Anthropic-compatible endpoint and a default +model so they can be used immediately after adding a key. The easiest path is the menu bar: @@ -125,28 +131,31 @@ settings. It writes: ```json { "env": { - "ANTHROPIC_BASE_URL": "http://localhost:8484", - "ANTHROPIC_AUTH_TOKEN": ":", + "ANTHROPIC_BASE_URL": "https://provider.example.com", + "ANTHROPIC_API_KEY": "", "API_TIMEOUT_MS": "600000" } } ``` -When the selected account has a default model, CoderSwitch also writes -`ANTHROPIC_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`, -`ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_OPUS_MODEL` to that -model. Switching to **Official Claude** removes only the CoderSwitch-managed env -keys. +When the selected account has a default model, CoderSwitch writes the Claude +Code model-family env vars. The account editor can map Opus, Sonnet, and Haiku +separately via `ANTHROPIC_DEFAULT_OPUS_MODEL`, +`ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. +Switching to **Official Claude** removes only the CoderSwitch-managed env keys. You can also launch Claude Code directly from Settings > Accounts with -**Open Claude Code** on an Anthropic-compatible account. That starts the proxy if -needed, asks for a project folder, writes the same Claude Code settings, and -opens Terminal with Claude Code pointed at CoderSwitch for that account. +**Open Claude Code** on an Anthropic-compatible account. That asks for a project +folder, writes an isolated temp Claude Code settings file, and opens Terminal +with Claude Code pointed directly at that account's Anthropic-compatible +endpoint and stored API key. The local proxy is still available for manual +routing and request logging, but the button does not route direct API-key +accounts through the proxy. Manual setup works too: ```bash -export ANTHROPIC_BASE_URL=http://localhost:8484 +export ANTHROPIC_BASE_URL=http://localhost:8484/claude export ANTHROPIC_AUTH_TOKEN='' claude ``` @@ -159,13 +168,13 @@ the menu bar quick switch handles this automatically. **Claude Code:** ```bash -export ANTHROPIC_BASE_URL=http://localhost:8484 +export ANTHROPIC_BASE_URL=http://localhost:8484/claude export ANTHROPIC_AUTH_TOKEN='' ``` **Codex / OpenAI clients:** ```bash -export OPENAI_BASE_URL=http://localhost:8484/v1 +export OPENAI_BASE_URL=http://localhost:8484/codex/v1 export OPENAI_API_KEY='' ``` diff --git a/project.yml b/project.yml index 1f8d425..bf96c00 100644 --- a/project.yml +++ b/project.yml @@ -15,20 +15,16 @@ settings: ENABLE_USER_SCRIPT_SANDBOXING: NO packages: - Hummingbird: - url: https://github.com/hummingbird-project/hummingbird - from: 2.0.0 - GRDB: - url: https://github.com/groue/GRDB.swift - from: 7.0.0 + CoderSwitchCore: + path: ../coderswitch-studio/Packages/CoderSwitchCore targets: CoderSwitch: type: application platform: macOS sources: - - path: CoderSwitch - resources: + - path: CoderSwitch/App + - path: CoderSwitch/Views - path: CoderSwitch/Resources optional: true info: @@ -51,17 +47,15 @@ targets: com.apple.security.network.server: true settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.forbes.CoderSwitch + PRODUCT_BUNDLE_IDENTIFIER: dev.forbes.CoderSwitchMenuBar GENERATE_INFOPLIST_FILE: NO INFOPLIST_FILE: CoderSwitch/Info.plist CODE_SIGN_ENTITLEMENTS: CoderSwitch/CoderSwitch.entitlements ENABLE_HARDENED_RUNTIME: YES COMBINE_HIDPI_IMAGES: YES dependencies: - - package: Hummingbird - product: Hummingbird - - package: GRDB - product: GRDB + - package: CoderSwitchCore + product: CoderSwitchCore CoderSwitchTests: type: bundle.unit-test platform: macOS @@ -74,6 +68,8 @@ targets: BUNDLE_LOADER: "$(TEST_HOST)" dependencies: - target: CoderSwitch + - package: CoderSwitchCore + product: CoderSwitchCore schemes: CoderSwitch: From 041a1538ae7108c07ff33f3626c1b4096519e6dc Mon Sep 17 00:00:00 2001 From: Forbes Fields Date: Thu, 20 Aug 2026 17:21:53 -0600 Subject: [PATCH 2/3] Remove duplicate menu bar placeholder --- CoderSwitch/App/CoderSwitchApp.swift | 112 --------------------------- 1 file changed, 112 deletions(-) diff --git a/CoderSwitch/App/CoderSwitchApp.swift b/CoderSwitch/App/CoderSwitchApp.swift index f417bae..050c354 100644 --- a/CoderSwitch/App/CoderSwitchApp.swift +++ b/CoderSwitch/App/CoderSwitchApp.swift @@ -38,7 +38,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { reason: "CoderSwitch menu bar utility" ) NativeStatusItemController.shared.install() - MenuBarPlaceholderController.shared.install() } func applicationWillTerminate(_ notification: Notification) { @@ -62,116 +61,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -// MARK: - Codex placeholder (remove after user confirms real menu bar icon works) - -@MainActor -final class MenuBarPlaceholderController: NSObject { - static let shared = MenuBarPlaceholderController() - - private var windows: [NSWindow] = [] - private weak var context: AppContext? - private weak var oauthStore: OAuthStore? - private var popover: NSPopover? - private weak var highlightedButton: NSButton? - - private override init() { - super.init() - } - - func configure(context: AppContext, oauthStore: OAuthStore) { - self.context = context - self.oauthStore = oauthStore - } - - func install() { - guard context != nil, oauthStore != nil else { return } - windows.forEach { $0.close() } - windows = NSScreen.screens.map { screen in - let size = NSSize(width: 46, height: 22) - let frame = NSRect( - x: max(screen.frame.minX + 24, screen.frame.maxX - 760), - y: screen.frame.maxY - size.height - 2, - width: size.width, - height: size.height - ) - - let button = NSButton(frame: NSRect(origin: .zero, size: size)) - button.isBordered = false - button.wantsLayer = true - button.layer?.cornerRadius = 7 - button.layer?.masksToBounds = true - button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor - button.attributedTitle = NSAttributedString( - string: "CS", - attributes: [ - .font: NSFont.monospacedSystemFont(ofSize: 13, weight: .bold), - .foregroundColor: NSColor.systemBlue - ] - ) - button.toolTip = "CoderSwitch (placeholder)" - button.setAccessibilityTitle("CoderSwitch") - button.setAccessibilityIdentifier("CoderSwitch.PlaceholderStatusItem") - button.target = self - button.action = #selector(togglePopover(_:)) - - let panel = NSPanel( - contentRect: frame, - styleMask: [.borderless, .nonactivatingPanel], - backing: .buffered, - defer: false - ) - panel.contentView = button - panel.isOpaque = false - panel.backgroundColor = .clear - panel.hasShadow = false - panel.hidesOnDeactivate = false - panel.isReleasedWhenClosed = false - panel.level = .statusBar - panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenAuxiliary] - panel.orderFrontRegardless() - return panel - } - } - - @objc private func togglePopover(_ sender: NSButton) { - guard let context, let oauthStore else { return } - - if let popover, popover.isShown { - closePopover() - return - } - - let popover = StatusPopoverPresenter.makePopover( - context: context, - oauthStore: oauthStore, - delegate: self, - openSettings: { [weak self] in - self?.closePopover() - AppShell.shared.showSettingsWindow() - } - ) - self.popover = popover - highlightedButton = sender - sender.isHighlighted = true - popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) - } - - private func closePopover() { - popover?.close() - popover = nil - highlightedButton?.isHighlighted = false - highlightedButton = nil - } -} - -extension MenuBarPlaceholderController: NSPopoverDelegate { - func popoverWillClose(_ notification: Notification) { - popover = nil - highlightedButton?.isHighlighted = false - highlightedButton = nil - } -} - @MainActor final class AppShell: NSObject { static let shared = AppShell() @@ -186,7 +75,6 @@ final class AppShell: NSObject { self.context = context self.oauthStore = oauthStore NativeStatusItemController.shared.configure(context: context, oauthStore: oauthStore) - MenuBarPlaceholderController.shared.configure(context: context, oauthStore: oauthStore) } func showSettingsWindow() { From a35889ab7f5bb65120e5bf37101d758fab04ee38 Mon Sep 17 00:00:00 2001 From: Forbes Fields Date: Thu, 10 Sep 2026 12:57:09 -0600 Subject: [PATCH 3/3] Remove global ATS exception and document Keychain-backed secrets - Drop NSAllowsArbitraryLoads from project.yml and Info.plist; custom provider endpoints are validated at save time in CoderSwitchCore (https required off-loopback). - SECURITY.md: Secret Handling and Known Hardening Items now reflect the Keychain-backed SecretBox root key, Keychain proxy admin key, OAuth state validation, and loopback-only callback listener. Pairs with the coderswitch-studio core PR (secret migration, OAuth state/callback hardening, endpoint validation, migration tests). --- CoderSwitch/Info.plist | 5 ----- SECURITY.md | 41 ++++++++++++++++++++++++++++------------- project.yml | 2 -- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/CoderSwitch/Info.plist b/CoderSwitch/Info.plist index a2df3a8..f2e8d19 100644 --- a/CoderSwitch/Info.plist +++ b/CoderSwitch/Info.plist @@ -24,11 +24,6 @@ 14.0 LSUIElement - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSHumanReadableCopyright © 2026 f9Labs diff --git a/SECURITY.md b/SECURITY.md index d2a3fdb..9e7abb2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -31,11 +31,14 @@ GitHub before publishing details. Include: `.coderswitchconfig` exports. - API keys and OAuth tokens are encrypted in CoderSwitch's local SQLite database with CryptoKit `SecretBox`. -- The current `SecretBox` root key is stored as a mode-0600 file in - `~/Library/Application Support/CoderSwitch/.master.key`. This protects - against casual database inspection, but it is not equivalent to Keychain - protection against same-user malware, broad filesystem access, or copied - backups. +- The `SecretBox` root key is stored in the macOS Keychain + (`kSecAttrAccessibleWhenUnlockedThisDeviceOnly`). Installations that still + have a legacy `.master.key` file in `~/Library/Application Support/CoderSwitch/` + migrate it into the Keychain on first launch; the file is deleted only after + the Keychain write is verified. +- The proxy admin key is stored in the macOS Keychain, not in SQLite. It is + imported from older plaintext databases on first launch, after which the + database column is scrubbed. - Config backup files (`*.coderswitchconfig`) contain decrypted API keys, OAuth tokens, proxy settings, and the proxy admin key. Keep them private and delete them when no longer needed. @@ -60,17 +63,29 @@ reserved for explicit local development endpoints such as `localhost` or ## Known Hardening Items -Before broad distribution, the highest-value hardening work is: +Done: -- Move the `SecretBox` root key from `.master.key` into macOS Keychain and - migrate existing users safely. -- Add OAuth `state` generation and validation. -- Restrict the OAuth callback listener and reject unexpected callback paths. -- Remove global App Transport Security arbitrary loads and validate custom - endpoints before saving them. -- Move the proxy admin key out of plaintext SQLite storage. +- The `SecretBox` root key lives in the macOS Keychain; legacy `.master.key` + files are migrated and deleted automatically. +- OAuth authorization requests carry a random `state` that is validated on the + callback before any code is used. +- The OAuth callback listener binds to `127.0.0.1` only, drops non-loopback + connections, and rejects requests whose path or `state` does not match the + pending flow. +- Global App Transport Security arbitrary loads are removed; custom provider + endpoints must be `https://` (or loopback `http://`) and must not embed + credentials. +- The proxy admin key is stored in the Keychain instead of plaintext SQLite. + +Still open: + +- Rotate/remove the committed Google OAuth client secret if it is a real + confidential-client value (see the security audit's H-4 finding). - Decide whether the app should be sandboxed for public distribution, and document any intentional exceptions. +- Consider rotating the SecretBox root key (and stored credentials) if any + real credentials were stored before the Keychain migration landed, since + older app-support directories may already have been copied or backed up. ## Dependency and Release Checks diff --git a/project.yml b/project.yml index bf96c00..eaeda34 100644 --- a/project.yml +++ b/project.yml @@ -37,8 +37,6 @@ targets: LSMinimumSystemVersion: "14.0" LSUIElement: true NSHumanReadableCopyright: "© 2026 f9Labs" - NSAppTransportSecurity: - NSAllowsArbitraryLoads: true entitlements: path: CoderSwitch/CoderSwitch.entitlements properties: