diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 99eed3f8..d2248b8d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 0333B3CE8F189DD1BEC4AD26 /* MenuBarSections.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */; }; 0431AE1DBEE36C90C7F39C19 /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */; }; 046C133967B32BBF9205EBB1 /* LLMIOFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D610FCA3A97249DCCE7D0B8 /* LLMIOFileHandler.swift */; }; + 0479478CBE8D5387119C3E35 /* OpenAICompatibleSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5611C5D712701A8B10C731 /* OpenAICompatibleSuggestionEngine.swift */; }; 056EA46914B45722D19D438E /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */; }; 057CEA7858012C1501F1785C /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */; }; 05CC25E51682528CE2E73446 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; @@ -61,6 +62,7 @@ 1059A308A31A94576FF29CF0 /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E72A3972E15749337539C2D /* EmojiRecents.swift */; }; 108C109E767888DEB2791F8E /* EmojiUsageModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC48B188C6E6E263B876621D /* EmojiUsageModels.swift */; }; 111BEFF15FEF2CD3DED887A5 /* HuggingFaceSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F426127917FCB1096134732 /* HuggingFaceSearchService.swift */; }; + 122D005AEDDD4D3EADEB0B53 /* OpenAICompatibleAPIClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2CD766561AE027DF0BD341A /* OpenAICompatibleAPIClientTests.swift */; }; 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 1450746C690B3D98203B71EC /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ED42C4BDD0C521101AF95E /* DeviceInfo.swift */; }; 14611368270D611A2D5DC67E /* SettingsRowLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */; }; @@ -81,7 +83,9 @@ 19386985A3A91D0843092086 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; 19CA1BF8B508E0E219EF4485 /* SuggestionEngineModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470A7DAE3D6A2C873B395AE3 /* SuggestionEngineModelsTests.swift */; }; 19CB55B62977376E9AE8D428 /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F01FAC4F57EB08471521196 /* VisualContextStartCoalescer.swift */; }; + 19E59FD9A0707751D30D93E3 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B68641C8203AEB8CBE6D98 /* OpenAICompatibleEndpointModels.swift */; }; 1A4A2FCA8F08258A8A70A057 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; + 1AAB8B5A80EBB63948674DE8 /* OpenAICompatibleEndpointModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B68641C8203AEB8CBE6D98 /* OpenAICompatibleEndpointModels.swift */; }; 1AEA5D46AFB9095716406788 /* ApplicationBundleMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352AF5B2834FEE1F597394E4 /* ApplicationBundleMetadata.swift */; }; 1B3FFCB9A979F49BF86EAAD4 /* ScreenshotContextGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BFD19A159680A495EE02FD /* ScreenshotContextGeneratorTests.swift */; }; 1B62024FC44DCAC1E4B7E048 /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; }; @@ -175,6 +179,7 @@ 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; 39836F8D77DA6A4B3E3426FE /* GPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = BEF60972B2D88E4EC4841AB0 /* GPL-3.0.txt */; }; 3985F0F2B3178DBB945B1064 /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CF416511099C6818110F01 /* CompletionRenderModePolicy.swift */; }; + 39B31DBE07288712A1ACA202 /* OpenAICompatibleAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C455CB8D11E89CEDA0B3AE /* OpenAICompatibleAPIClient.swift */; }; 3AB45217DFC86AFC98C374D6 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */; }; 3AC2F7F221F7C59242B06DFC /* InputSuppressionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */; }; 3AFDA4A5BE9486A1B367815B /* SettingsNavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1AC5EC06664F49A6AE2B17 /* SettingsNavigationModel.swift */; }; @@ -339,6 +344,7 @@ 6E01052209B73D7361C12CEF /* ClipboardContentDistiller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */; }; 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; + 70998EEDBB8B9953CB043991 /* OpenAICompatibleCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE343D89A8632B629B38D351 /* OpenAICompatibleCredentialStore.swift */; }; 709F365A846B908D953FA92D /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; 7179FB0EC6411166CCD79F6B /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F283E5B403F9FEBFB4BA04A /* CompositionInputModeClassifier.swift */; }; 7291020840F2164EBB764FAF /* CalendarAccessibilityCaptureGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049102F0ED85618106A042C4 /* CalendarAccessibilityCaptureGuard.swift */; }; @@ -437,6 +443,7 @@ 96C3128BCB17A05A7C7DEFF7 /* StaticTextRunWalkThrottleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */; }; 96ECE89BD96CF93CC159B437 /* MenuBarRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F32455AE743D1D0E9E2CB38 /* MenuBarRecoveryPolicy.swift */; }; 9706D778FB549E9E7AE05F4F /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; + 972755DA269C9A5C34D64FBC /* OpenAICompatibleCredentialStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE343D89A8632B629B38D351 /* OpenAICompatibleCredentialStore.swift */; }; 97ECEF5AA17B26FA6F187461 /* FocusCapabilityFlickerGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */; }; 97EF76E6B7A1AFB3FA4879D1 /* LGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7513810E78F3C94FE972EB07 /* LGPL-3.0.txt */; }; 982BA68A53CEE0F45D41F3D3 /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */; }; @@ -611,6 +618,7 @@ D90C889A623A928F4F5FDC7B /* HardwareCapabilityProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41BBD5A4BA08CABE77860886 /* HardwareCapabilityProbe.swift */; }; D9C51DEDF01033E276A479CE /* AXTextGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */; }; DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */; }; + DA9360725086404C953BCE5A /* OpenAICompatibleAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96C455CB8D11E89CEDA0B3AE /* OpenAICompatibleAPIClient.swift */; }; DAD77998F793468D4D64B705 /* DateMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F671FE53CDEAE9091EFBCE45 /* DateMacroEvaluator.swift */; }; DB1310FF3576ACA6472C4DB1 /* TrailingDuplicationFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */; }; DB7BE0A914BF75654D9EA6FF /* InsertedTextAdvance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1267FF524A170ACC144E919 /* InsertedTextAdvance.swift */; }; @@ -692,6 +700,7 @@ FC51AE2C3F21263B11CA06B6 /* CompletionSeamGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6D36DB66629CF22C1783945 /* CompletionSeamGuardTests.swift */; }; FC6B0524B774F20C18BD6889 /* OnboardingTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */; }; FCC571EC239846F06007BFCA /* CotabbyAppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */; }; + FDA8E2A77C8D65C52C72159F /* OpenAICompatibleSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD5611C5D712701A8B10C731 /* OpenAICompatibleSuggestionEngine.swift */; }; FDBD858EDABA08FBBE0C7ED3 /* InlineCommandCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D77C99769239E4B33D6B2C9 /* InlineCommandCoordinator.swift */; }; FE3AE9F51E1F616D843E7BFA /* EmojiUsageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE35C7770405ED368AA02448 /* EmojiUsageStore.swift */; }; FE4191A7C84E547DCD4F8B44 /* OnboardingTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */; }; @@ -945,6 +954,7 @@ 94B0830FBE4F2E239F670DBA /* AppSurfaceClassifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSurfaceClassifier.swift; sourceTree = ""; }; 960F3FDBF283347594F30494 /* SuggestionSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsStore.swift; sourceTree = ""; }; 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistiller.swift; sourceTree = ""; }; + 96C455CB8D11E89CEDA0B3AE /* OpenAICompatibleAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleAPIClient.swift; sourceTree = ""; }; 974A8708D2006767BD76862A /* MacroEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroEngine.swift; sourceTree = ""; }; 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceMetricsStore.swift; sourceTree = ""; }; 98E25C90F99356EB5E249225 /* SuggestionCoordinatorInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorInputTests.swift; sourceTree = ""; }; @@ -970,6 +980,7 @@ 9E72A3972E15749337539C2D /* EmojiRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecents.swift; sourceTree = ""; }; A1267FF524A170ACC144E919 /* InsertedTextAdvance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertedTextAdvance.swift; sourceTree = ""; }; A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = ""; }; + A1B68641C8203AEB8CBE6D98 /* OpenAICompatibleEndpointModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleEndpointModels.swift; sourceTree = ""; }; A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPaneView.swift; sourceTree = ""; }; A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutPaneView.swift; sourceTree = ""; }; @@ -1056,14 +1067,17 @@ CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTextGeometryResolver.swift; sourceTree = ""; }; CBD5FCB8CC56AA6138382B2C /* FieldEdgeIconIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldEdgeIconIndicatorView.swift; sourceTree = ""; }; CC1EDFB535AAA2EE0D67828A /* CotabbyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyApp.swift; sourceTree = ""; }; + CD5611C5D712701A8B10C731 /* OpenAICompatibleSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleSuggestionEngine.swift; sourceTree = ""; }; CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayStabilityGateTests.swift; sourceTree = ""; }; CE0AA0503128B0FC3951D700 /* SuggestionSessionReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconciler.swift; sourceTree = ""; }; + CE343D89A8632B629B38D351 /* OpenAICompatibleCredentialStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleCredentialStore.swift; sourceTree = ""; }; CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogHandler.swift; sourceTree = ""; }; D0AF9479EF020071CA64CCC1 /* HuggingFaceModelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModelsTests.swift; sourceTree = ""; }; D1123AB515110BD0CBA39490 /* HomePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePaneView.swift; sourceTree = ""; }; D12ABBCE23A946C22894945B /* DecodeStopPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicy.swift; sourceTree = ""; }; D1AA6A6F4C3A54B5DA2A0022 /* StreamedGhostTextPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamedGhostTextPolicyTests.swift; sourceTree = ""; }; D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; + D2CD766561AE027DF0BD341A /* OpenAICompatibleAPIClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAICompatibleAPIClientTests.swift; sourceTree = ""; }; D2D0FE44138BCA8B2EE05AFE /* TypoCaseTransferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoCaseTransferTests.swift; sourceTree = ""; }; D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardRelevanceFilter.swift; sourceTree = ""; }; @@ -1404,6 +1418,7 @@ BF4BB93056F291FD24EFAD22 /* LanguageCatalog.swift */, A804F4DB6FD9BC8C27B2B65F /* LlamaRuntimeModels.swift */, 4451D6673112575DF24C4A48 /* OnboardingTemplate.swift */, + A1B68641C8203AEB8CBE6D98 /* OpenAICompatibleEndpointModels.swift */, 979A7867966180A545BB44C4 /* PerformanceMetricsStore.swift */, 9D82FFC568527700EC17C07D /* PermissionModels.swift */, 60629DFE309C1A4BD8A7FB3B /* RuntimeBootstrapModel.swift */, @@ -1554,6 +1569,7 @@ D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */, 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */, 28B7EB84781C0ED57844585E /* OnboardingTemplateTests.swift */, + D2CD766561AE027DF0BD341A /* OpenAICompatibleAPIClientTests.swift */, EC582636750B78D497119845 /* PerDomainDisableSettingsTests.swift */, 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */, E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */, @@ -1839,6 +1855,9 @@ 944065A858D9BC936CB12B23 /* LlamaRuntimeCore.swift */, A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */, BE04620C905041680116BE80 /* LlamaSuggestionEngine.swift */, + 96C455CB8D11E89CEDA0B3AE /* OpenAICompatibleAPIClient.swift */, + CE343D89A8632B629B38D351 /* OpenAICompatibleCredentialStore.swift */, + CD5611C5D712701A8B10C731 /* OpenAICompatibleSuggestionEngine.swift */, 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */, ); path = Runtime; @@ -2175,6 +2194,10 @@ FE4191A7C84E547DCD4F8B44 /* OnboardingTemplate.swift in Sources */, 087EAFD68591401E870EFEC3 /* OnboardingTemplateFeatureList.swift in Sources */, B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */, + 39B31DBE07288712A1ACA202 /* OpenAICompatibleAPIClient.swift in Sources */, + 70998EEDBB8B9953CB043991 /* OpenAICompatibleCredentialStore.swift in Sources */, + 19E59FD9A0707751D30D93E3 /* OpenAICompatibleEndpointModels.swift in Sources */, + 0479478CBE8D5387119C3E35 /* OpenAICompatibleSuggestionEngine.swift in Sources */, C5ADE88B2504F252278E3DD5 /* OverlayController.swift in Sources */, B335B04A3EB50E51FF9C8C0F /* PerDomainDisableSettings.swift in Sources */, B02F46E94F74BAEBB90E165A /* PerformanceMetricsStore.swift in Sources */, @@ -2427,6 +2450,10 @@ FC6B0524B774F20C18BD6889 /* OnboardingTemplate.swift in Sources */, 64DA031AEAC20AC6C852A24A /* OnboardingTemplateFeatureList.swift in Sources */, DF8794793110A8ED234CBA96 /* OnboardingTemplateRecommender.swift in Sources */, + DA9360725086404C953BCE5A /* OpenAICompatibleAPIClient.swift in Sources */, + 972755DA269C9A5C34D64FBC /* OpenAICompatibleCredentialStore.swift in Sources */, + 1AAB8B5A80EBB63948674DE8 /* OpenAICompatibleEndpointModels.swift in Sources */, + FDA8E2A77C8D65C52C72159F /* OpenAICompatibleSuggestionEngine.swift in Sources */, 0DDC0CFF5558A8F4355836B2 /* OverlayController.swift in Sources */, A67B3F97DAEBEC7CA9B1215E /* PerDomainDisableSettings.swift in Sources */, 0E4009CFC86608F055909967 /* PerformanceMetricsStore.swift in Sources */, @@ -2628,6 +2655,7 @@ DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */, D648DD70AD847F67B77CE052 /* OnboardingTemplateRecommenderTests.swift in Sources */, 0160F9D9929465E6B6A3385F /* OnboardingTemplateTests.swift in Sources */, + 122D005AEDDD4D3EADEB0B53 /* OpenAICompatibleAPIClientTests.swift in Sources */, C9426ADFCA2EC1D11D841A2A /* PerDomainDisableSettingsTests.swift in Sources */, 8B26F7B26358438D6EB88C2E /* PerformanceMetricsStoreTests.swift in Sources */, 15FA56CEF6FB5FF54C2FBA6F /* PermissionAndContextModelTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 0f1536eb..5535fec0 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -15,6 +15,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { private let permissionManager: PermissionManager private let permissionGuidanceController: PermissionGuidanceController private let suggestionSettings: SuggestionSettingsModel + private let openAICompatibleConnectionModel: OpenAICompatibleConnectionModel private let foundationModelAvailabilityService: FoundationModelAvailabilityService private let runtimeModel: RuntimeBootstrapModel private let modelDownloadManager: ModelDownloadManager @@ -39,6 +40,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { permissionManager: PermissionManager, permissionGuidanceController: PermissionGuidanceController, suggestionSettings: SuggestionSettingsModel, + openAICompatibleConnectionModel: OpenAICompatibleConnectionModel, foundationModelAvailabilityService: FoundationModelAvailabilityService, runtimeModel: RuntimeBootstrapModel, modelDownloadManager: ModelDownloadManager, @@ -53,6 +55,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.permissionManager = permissionManager self.permissionGuidanceController = permissionGuidanceController self.suggestionSettings = suggestionSettings + self.openAICompatibleConnectionModel = openAICompatibleConnectionModel self.foundationModelAvailabilityService = foundationModelAvailabilityService self.runtimeModel = runtimeModel self.modelDownloadManager = modelDownloadManager @@ -81,6 +84,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { permissionManager: permissionManager, permissionGuidanceController: permissionGuidanceController, suggestionSettings: suggestionSettings, + openAICompatibleConnectionModel: openAICompatibleConnectionModel, foundationModelAvailabilityService: foundationModelAvailabilityService, runtimeModel: runtimeModel, modelDownloadManager: modelDownloadManager, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 5b83e269..83ac9133 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -27,8 +27,10 @@ extension SuggestionCoordinator { // fast, calmer when it is slow (fewer doomed generations to cancel). The configured value // is the fallback until a first latency exists. let debounceMilliseconds = DebouncePolicy.milliseconds( - lastGenerationLatencyMilliseconds: latestLatencyMilliseconds, - fallback: settingsSnapshot.debounceMilliseconds + lastGenerationLatencyMilliseconds: + lastLatencyByEngine[settingsSnapshot.selectedEngine], + fallback: settingsSnapshot.debounceMilliseconds, + engine: settingsSnapshot.selectedEngine ) // The debounce clock starts at the keystroke, not here. The host-publish poll has already // consumed real wall time waiting for the host to publish the keystroke to AX, and that @@ -641,6 +643,12 @@ extension SuggestionCoordinator { return } + // Record every completed result, including output later suppressed by normalization or seam + // checks. Those requests still consumed backend time and are exactly the evidence the next + // debounce needs to avoid another burst of doomed HTTP work. + lastLatencyByEngine[settingsSnapshot.selectedEngine] = + Int((result.latency * 1_000).rounded()) + // The free-running focus poll keeps capturing while the engine generates, so a fresh // capture often already exists here; only pay a synchronous AX walk when it does not. // Any keystroke during generation bumped the work id (checked above), and non-keyboard diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index f8a42fdc..a2fe82dc 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -77,6 +77,11 @@ final class SuggestionCoordinator: ObservableObject { // Async work and active-session storage now live in dedicated collaborators below. var cancellables = Set() var settingsSnapshot: SuggestionSettingsSnapshot + /// Last completed round trip per backend, retained across keystrokes. `latestLatencyMilliseconds` + /// belongs to the currently displayed suggestion and is cleared as soon as the user types, so it + /// cannot drive adaptive scheduling. Keeping this separate lets a slow HTTP backend retain an + /// appropriate debounce without leaking that latency into the in-process engines. + var lastLatencyByEngine: [SuggestionEngineKind: Int] = [:] // Synchronous input/focus callbacks cannot directly `await`, so resets are represented as a // barrier task that the next generation must cross before it can ask the runtime for output. var cacheResetSequence: UInt64 = 0 diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 024d42f2..4e755b4a 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -264,8 +264,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { switch suggestionSettings.selectedEngine { case .llamaOpenSource: runtimeModel.startIfNeeded() - case .appleIntelligence: - break + case .appleIntelligence, .openAICompatible: + // Switching away must release Metal buffers and the mapped GGUF. Otherwise an Ollama + // user still pays the duplicate memory cost the external endpoint is meant to avoid. + runtimeModel.stop() } } diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index d0f7af04..99c2642d 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -21,6 +21,7 @@ final class CotabbyAppEnvironment { let appUpdateManager: AppUpdateManager let permissionGuidanceController: PermissionGuidanceController let suggestionSettings: SuggestionSettingsModel + let openAICompatibleConnectionModel: OpenAICompatibleConnectionModel let foundationModelAvailabilityService: FoundationModelAvailabilityService let powerSourceMonitor: PowerSourceMonitor /// Detects when a composing input method (Japanese kana, Chinese pinyin, Korean hangul, ...) is @@ -53,7 +54,15 @@ final class CotabbyAppEnvironment { let runtimeManager = LlamaRuntimeManager() let runtimeModel = RuntimeBootstrapModel(runtimeManager: runtimeManager) let modelDownloadManager = ModelDownloadManager() - let suggestionSettings = SuggestionSettingsModel(configuration: configuration) + let endpointCredentialStore = KeychainOpenAICompatibleCredentialStore() + let suggestionSettings = SuggestionSettingsModel( + configuration: configuration, + endpointCredentialStore: endpointCredentialStore + ) + let openAICompatibleClient = OpenAICompatibleAPIClient() + let openAICompatibleConnectionModel = OpenAICompatibleConnectionModel( + client: openAICompatibleClient + ) let foundationModelAvailabilityService = FoundationModelAvailabilityService() let powerSourceMonitor = PowerSourceMonitor() let keyboardInputSourceMonitor = KeyboardInputSourceMonitor() @@ -179,6 +188,21 @@ final class CotabbyAppEnvironment { qualityMetricsStore: qualityMetricsStore, llamaModelNameProvider: { [weak runtimeManager] in runtimeManager?.currentModelFilename + }, + openAICompatibleEngine: OpenAICompatibleSuggestionEngine( + client: openAICompatibleClient, + configurationProvider: { [weak suggestionSettings] in + guard let suggestionSettings else { + throw OpenAICompatibleClientError.invalidResponse + } + return try suggestionSettings.openAICompatibleConfiguration + }, + apiKeyProvider: { [weak suggestionSettings] in + try suggestionSettings?.openAICompatibleAPIKey() + } + ), + endpointModelNameProvider: { [weak suggestionSettings] in + suggestionSettings?.openAICompatibleModelName.nonEmpty } ) @@ -191,6 +215,7 @@ final class CotabbyAppEnvironment { permissionManager: permissionManager, permissionGuidanceController: permissionGuidanceController, suggestionSettings: suggestionSettings, + openAICompatibleConnectionModel: openAICompatibleConnectionModel, foundationModelAvailabilityService: foundationModelAvailabilityService, runtimeModel: runtimeModel, modelDownloadManager: modelDownloadManager, @@ -287,6 +312,7 @@ final class CotabbyAppEnvironment { self.appUpdateManager = appUpdateManager self.permissionGuidanceController = permissionGuidanceController self.suggestionSettings = suggestionSettings + self.openAICompatibleConnectionModel = openAICompatibleConnectionModel self.foundationModelAvailabilityService = foundationModelAvailabilityService self.powerSourceMonitor = powerSourceMonitor self.keyboardInputSourceMonitor = keyboardInputSourceMonitor @@ -328,6 +354,7 @@ final class CotabbyAppEnvironment { .store(in: &cancellables) observePowerSourceProfileSwitching() + observeOpenAICompatibleSelection() } /// Applies the user's per-power-source profile (engine + model) whenever anything that could @@ -342,8 +369,10 @@ final class CotabbyAppEnvironment { suggestionSettings.$isPowerBasedModelSwitchingEnabled.map { _ in () }.eraseToAnyPublisher(), suggestionSettings.$batteryEngine.map { _ in () }.eraseToAnyPublisher(), suggestionSettings.$batteryModelFilename.map { _ in () }.eraseToAnyPublisher(), + suggestionSettings.$batteryEndpointModelName.map { _ in () }.eraseToAnyPublisher(), suggestionSettings.$pluggedInEngine.map { _ in () }.eraseToAnyPublisher(), suggestionSettings.$pluggedInModelFilename.map { _ in () }.eraseToAnyPublisher(), + suggestionSettings.$pluggedInEndpointModelName.map { _ in () }.eraseToAnyPublisher(), runtimeModel.$availableModels.map { _ in () }.eraseToAnyPublisher() ] @@ -400,6 +429,53 @@ final class CotabbyAppEnvironment { Task { await runtimeModel.selectModel(filename) } + case .openAICompatible(let modelName): + guard !modelName.isEmpty else { return } + suggestionSettings.setOpenAICompatibleModelName(modelName) + suggestionSettings.selectEngine(.openAICompatible) } } + + /// Connect once when the endpoint engine becomes active. Configuration edits only invalidate + /// discovery so status never describes a different URL, mode, credential, or active model; + /// Settings still owns explicit Connect/Return refreshes and avoids network traffic per keystroke. + private func observeOpenAICompatibleSelection() { + suggestionSettings.$selectedEngine + .removeDuplicates() + .sink { [weak self] engine in + guard engine == .openAICompatible, let self else { return } + Task { [weak self] in + guard let self else { return } + do { + let configuration = try self.suggestionSettings.openAICompatibleConfiguration + let apiKey = try self.suggestionSettings.openAICompatibleAPIKey() + await self.openAICompatibleConnectionModel.refresh( + configuration: configuration, + apiKey: apiKey + ) + } catch { + self.openAICompatibleConnectionModel.invalidate() + } + } + } + .store(in: &cancellables) + + let configurationChanges: [AnyPublisher] = [ + suggestionSettings.$openAICompatibleBaseURL.dropFirst().map { _ in () }.eraseToAnyPublisher(), + suggestionSettings.$openAICompatibleModelName.dropFirst().map { _ in () }.eraseToAnyPublisher(), + suggestionSettings.$openAICompatibleAPIMode.dropFirst().map { _ in () }.eraseToAnyPublisher(), + suggestionSettings.$endpointCredentialRevision.dropFirst().map { _ in () }.eraseToAnyPublisher() + ] + + Publishers.MergeMany(configurationChanges) + .sink { [weak self] _ in + guard let self, self.suggestionSettings.selectedEngine == .openAICompatible else { return } + self.openAICompatibleConnectionModel.invalidate() + } + .store(in: &cancellables) + } +} + +private extension String { + var nonEmpty: String? { isEmpty ? nil : self } } diff --git a/Cotabby/Models/OpenAICompatibleEndpointModels.swift b/Cotabby/Models/OpenAICompatibleEndpointModels.swift new file mode 100644 index 00000000..4ddecb69 --- /dev/null +++ b/Cotabby/Models/OpenAICompatibleEndpointModels.swift @@ -0,0 +1,233 @@ +import Foundation + +/// File overview: +/// Defines the transport-independent values shared by Settings, the endpoint client, and routing. +/// Keeping URL policy and connection state here means SwiftUI never needs to understand HTTP paths, +/// IP ranges, or OpenAI response envelopes. + +/// Which OpenAI-compatible text-generation route Cotabby calls. +nonisolated enum OpenAICompatibleAPIMode: String, CaseIterable, Codable, Identifiable, Sendable { + case completions + case chatCompletions + + var id: String { rawValue } + + var displayLabel: String { + switch self { + case .completions: return "Completions" + case .chatCompletions: return "Chat Completions" + } + } + + var route: String { + switch self { + case .completions: return "completions" + case .chatCompletions: return "chat/completions" + } + } +} + +/// One model identifier returned by `GET /v1/models`. +nonisolated struct OpenAICompatibleModelOption: Decodable, Equatable, Identifiable, Sendable { + let id: String + let ownedBy: String? + + private enum CodingKeys: String, CodingKey { + case id + case ownedBy = "owned_by" + } +} + +/// Privacy classification for the configured server host. +nonisolated enum OpenAICompatibleHostScope: Equatable, Sendable { + case loopback + case localNetwork + case publicInternet +} + +/// A validated base URL plus the endpoint-specific model and request mode. +/// +/// This value is created at the boundary before every discovery or generation request. That makes +/// malformed URLs fail deterministically and prevents model discovery from normalizing a URL +/// differently than generation does. +nonisolated struct OpenAICompatibleEndpointConfiguration: Equatable, Sendable { + static let defaultBaseURLString = "http://127.0.0.1:11434/v1" + + let baseURL: URL + let modelName: String + let apiMode: OpenAICompatibleAPIMode + let hostScope: OpenAICompatibleHostScope + + init( + baseURLString: String, + modelName: String, + apiMode: OpenAICompatibleAPIMode + ) throws { + let candidate = baseURLString.trimmingCharacters(in: .whitespacesAndNewlines) + guard var components = URLComponents(string: candidate), + let rawScheme = components.scheme, + let host = components.host, + !host.isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil else { + throw OpenAICompatibleEndpointError.invalidBaseURL + } + + let scheme = rawScheme.lowercased() + guard scheme == "http" || scheme == "https" else { + throw OpenAICompatibleEndpointError.invalidBaseURL + } + + components.scheme = scheme + components.path = Self.normalizedBasePath(components.path) + guard let normalizedURL = components.url else { + throw OpenAICompatibleEndpointError.invalidBaseURL + } + + let scope = Self.hostScope(for: host) + if scheme == "http", scope == .publicInternet { + throw OpenAICompatibleEndpointError.insecurePublicHTTP + } + + baseURL = normalizedURL + self.modelName = modelName.trimmingCharacters(in: .whitespacesAndNewlines) + self.apiMode = apiMode + hostScope = scope + } + + func apiURL(path: String) -> URL { + baseURL.appendingPathComponent(path, isDirectory: false) + } + + var privacyWarning: String? { + switch hostScope { + case .loopback: + return nil + case .localNetwork: + return "Cotabby will send typed text and any enabled context to this server on your local network." + case .publicInternet: + return "This server is outside your Mac. Typed text and any enabled context will leave your device." + } + } + + private static func normalizedBasePath(_ rawPath: String) -> String { + var path = rawPath + while path.count > 1, path.hasSuffix("/") { + path.removeLast() + } + if path.isEmpty || path == "/" { + return "/v1" + } + return path + } + + static func hostScope(for rawHost: String) -> OpenAICompatibleHostScope { + let host = rawHost.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "[]")) + if host == "localhost" || host.hasSuffix(".localhost") || host == "::1" { + return .loopback + } + + if let octets = ipv4Octets(host) { + if octets[0] == 127 { return .loopback } + if octets[0] == 10 + || (octets[0] == 172 && (16...31).contains(octets[1])) + || (octets[0] == 192 && octets[1] == 168) + || (octets[0] == 169 && octets[1] == 254) { + return .localNetwork + } + return .publicInternet + } + + // A single-label DNS name is not proof of LAN scope: DNS may resolve it publicly. + if host.hasSuffix(".local") || host.hasPrefix("fc") || host.hasPrefix("fd") + || (host.hasPrefix("fe") && (host.dropFirst(2).first.map { "89ab".contains($0) } ?? false)) { + return .localNetwork + } + + return .publicInternet + } + + private static func ipv4Octets(_ host: String) -> [Int]? { + let pieces = host.split(separator: ".", omittingEmptySubsequences: false) + guard pieces.count == 4 else { return nil } + let octets = pieces.compactMap { Int($0) } + guard octets.count == 4, octets.allSatisfy({ (0...255).contains($0) }) else { return nil } + return octets + } +} + +nonisolated enum OpenAICompatibleEndpointError: LocalizedError, Equatable { + case invalidBaseURL + case insecurePublicHTTP + case emptyModelName + + var errorDescription: String? { + switch self { + case .invalidBaseURL: + return "Enter an HTTP or HTTPS base URL without credentials, a query, or a fragment." + case .insecurePublicHTTP: + return "Public endpoints must use HTTPS. HTTP is limited to this Mac or the local network." + case .emptyModelName: + return "Choose or enter a model before generating suggestions." + } + } +} + +/// UI-facing state for the latest endpoint model-discovery request. +enum OpenAICompatibleConnectionState: Equatable { + case idle + case connecting + case ready(modelCount: Int) + case failed(String) + + var summary: String { + switch self { + case .idle: return "Not connected" + case .connecting: return "Connecting…" + case .ready(let count): return count == 1 ? "Connected · 1 model" : "Connected · \(count) models" + case .failed(let message): return message + } + } + + var failureDetail: String? { + guard case .failed(let message) = self else { return nil } + return message + } +} + +/// Narrow credential boundary so production uses Keychain while tests use an in-memory fake. +@MainActor +protocol OpenAICompatibleCredentialStoring: AnyObject { + func readAPIKey() throws -> String? + func saveAPIKey(_ apiKey: String?) throws + func deleteAPIKey() throws +} + +/// Process-local credential store used by isolated settings tests and previews. +/// The app composition root explicitly injects the Keychain implementation. +@MainActor +final class InMemoryOpenAICompatibleCredentialStore: OpenAICompatibleCredentialStoring { + private var apiKey: String? + + init(apiKey: String? = nil) { + self.apiKey = apiKey + } + + // Xcode 26.0–26.3 can generate an invalid isolated deinitializer for a class stored behind a + // protocol existential in a MainActor-isolated owner. An explicit deinitializer avoids that + // Swift runtime bug; this can be removed once CI no longer supports those toolchains. + deinit {} + + func readAPIKey() throws -> String? { apiKey } + + func saveAPIKey(_ apiKey: String?) throws { + let value = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) + self.apiKey = value?.isEmpty == false ? value : nil + } + + func deleteAPIKey() throws { + apiKey = nil + } +} diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index 3e60be97..51a95e1b 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -10,6 +10,7 @@ import Foundation enum SuggestionEngineKind: String, CaseIterable, Equatable, Hashable, Sendable, Identifiable { case appleIntelligence case llamaOpenSource + case openAICompatible var id: String { rawValue } @@ -19,6 +20,8 @@ enum SuggestionEngineKind: String, CaseIterable, Equatable, Hashable, Sendable, return "Apple Intelligence" case .llamaOpenSource: return "Open Source" + case .openAICompatible: + return "Local Endpoint" } } @@ -31,6 +34,8 @@ enum SuggestionEngineKind: String, CaseIterable, Equatable, Hashable, Sendable, return "apple.logo" case .llamaOpenSource: return "cpu.fill" + case .openAICompatible: + return "network" } } @@ -40,6 +45,8 @@ enum SuggestionEngineKind: String, CaseIterable, Equatable, Hashable, Sendable, return false case .llamaOpenSource: return true + case .openAICompatible: + return false } } @@ -52,6 +59,7 @@ enum SuggestionEngineKind: String, CaseIterable, Equatable, Hashable, Sendable, enum PowerProfile: Equatable, Hashable { case appleIntelligence case llama(filename: String) + case openAICompatible(modelName: String) /// The engine this profile selects. Settings persists engine + filename separately, so this is /// the bridge from those two stored fields to a single picker selection (and back). @@ -61,6 +69,8 @@ enum PowerProfile: Equatable, Hashable { return .appleIntelligence case .llama: return .llamaOpenSource + case .openAICompatible: + return .openAICompatible } } } diff --git a/Cotabby/Models/SuggestionSettingsData.swift b/Cotabby/Models/SuggestionSettingsData.swift index 6c05fdcc..3d86138c 100644 --- a/Cotabby/Models/SuggestionSettingsData.swift +++ b/Cotabby/Models/SuggestionSettingsData.swift @@ -28,6 +28,9 @@ struct SuggestionSettingsData: Equatable { /// too large. See `SuggestionSettingsStore.clampedGhostTextSizeMultiplier` for the bounds. var ghostTextSizeMultiplier: Double var selectedEngine: SuggestionEngineKind + var openAICompatibleBaseURL: String + var openAICompatibleModelName: String + var openAICompatibleAPIMode: OpenAICompatibleAPIMode var selectedWordCountPreset: SuggestionWordCountPreset /// When true, generation uses `customWordCountLowWords...customWordCountHighWords` instead of /// the preset above. Stored alongside the preset (not replacing it) so toggling back from Custom @@ -101,6 +104,8 @@ struct SuggestionSettingsData: Equatable { var isPowerBasedModelSwitchingEnabled: Bool var batteryEngine: SuggestionEngineKind var batteryModelFilename: String + var batteryEndpointModelName: String var pluggedInEngine: SuggestionEngineKind var pluggedInModelFilename: String + var pluggedInEndpointModelName: String } diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index d4f35cec..8f2b27f2 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -1,6 +1,7 @@ import ApplicationServices import Combine import Foundation +import Logging /// Identifies one of the three user-configurable keyboard shortcuts so the recorder can ask which /// other action (if any) already owns a proposed key combination before committing it. @@ -51,6 +52,12 @@ final class SuggestionSettingsModel: ObservableObject { /// of the generation-facing `SuggestionSettingsSnapshot` — it changes presentation, not requests. @Published private(set) var ghostTextSizeMultiplier: Double @Published private(set) var selectedEngine: SuggestionEngineKind + @Published private(set) var openAICompatibleBaseURL: String + @Published private(set) var openAICompatibleModelName: String + @Published private(set) var openAICompatibleAPIMode: OpenAICompatibleAPIMode + /// Non-secret change token that lets lifecycle observers react to Keychain updates without + /// publishing the credential itself. + @Published private(set) var endpointCredentialRevision: UInt64 = 0 @Published private(set) var selectedWordCountPreset: SuggestionWordCountPreset /// When true, the active length budget reads `customWordCountLowWords...HighWords` and the /// curated `selectedWordCountPreset` is ignored for generation (but preserved as the value the @@ -132,12 +139,15 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var isPowerBasedModelSwitchingEnabled: Bool @Published private(set) var batteryEngine: SuggestionEngineKind @Published private(set) var batteryModelFilename: String + @Published private(set) var batteryEndpointModelName: String @Published private(set) var pluggedInEngine: SuggestionEngineKind @Published private(set) var pluggedInModelFilename: String + @Published private(set) var pluggedInEndpointModelName: String /// Owns the on-disk keys, defaults, migrations, and per-field writes. The facade holds one and /// routes every load and save through it. private let store: SuggestionSettingsStore + private let endpointCredentialStore: OpenAICompatibleCredentialStoring /// Retained so `resetToDefaults` can re-resolve the same first-launch values the app shipped with /// (a few defaults — word-count preset, profile name, debounce/poll cadence — come from here). @@ -166,13 +176,26 @@ final class SuggestionSettingsModel: ObservableObject { static let fadeInDurationStep = SuggestionSettingsStore.fadeInDurationStep static let maximumExtendedContextCharacters = SuggestionSettingsStore.maximumExtendedContextCharacters - init( + convenience init( configuration: SuggestionConfiguration, userDefaults: UserDefaults = .standard + ) { + self.init( + configuration: configuration, + userDefaults: userDefaults, + endpointCredentialStore: InMemoryOpenAICompatibleCredentialStore() + ) + } + + init( + configuration: SuggestionConfiguration, + userDefaults: UserDefaults = .standard, + endpointCredentialStore: OpenAICompatibleCredentialStoring ) { let store = SuggestionSettingsStore(userDefaults: userDefaults) let data = store.load(configuration: configuration) self.store = store + self.endpointCredentialStore = endpointCredentialStore self.configuration = configuration isGloballyEnabled = data.isGloballyEnabled @@ -185,6 +208,9 @@ final class SuggestionSettingsModel: ObservableObject { ghostTextOpacity = data.ghostTextOpacity ghostTextSizeMultiplier = data.ghostTextSizeMultiplier selectedEngine = data.selectedEngine + openAICompatibleBaseURL = data.openAICompatibleBaseURL + openAICompatibleModelName = data.openAICompatibleModelName + openAICompatibleAPIMode = data.openAICompatibleAPIMode selectedWordCountPreset = data.selectedWordCountPreset isUsingCustomWordCountRange = data.isUsingCustomWordCountRange customWordCountLowWords = data.customWordCountLowWords @@ -229,8 +255,10 @@ final class SuggestionSettingsModel: ObservableObject { isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine batteryModelFilename = data.batteryModelFilename + batteryEndpointModelName = data.batteryEndpointModelName pluggedInEngine = data.pluggedInEngine pluggedInModelFilename = data.pluggedInModelFilename + pluggedInEndpointModelName = data.pluggedInEndpointModelName schedulePauseExpirationIfNeeded() } @@ -255,6 +283,9 @@ final class SuggestionSettingsModel: ObservableObject { ghostTextOpacity = data.ghostTextOpacity ghostTextSizeMultiplier = data.ghostTextSizeMultiplier selectedEngine = data.selectedEngine + openAICompatibleBaseURL = data.openAICompatibleBaseURL + openAICompatibleModelName = data.openAICompatibleModelName + openAICompatibleAPIMode = data.openAICompatibleAPIMode selectedWordCountPreset = data.selectedWordCountPreset isUsingCustomWordCountRange = data.isUsingCustomWordCountRange customWordCountLowWords = data.customWordCountLowWords @@ -299,8 +330,16 @@ final class SuggestionSettingsModel: ObservableObject { isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled batteryEngine = data.batteryEngine batteryModelFilename = data.batteryModelFilename + batteryEndpointModelName = data.batteryEndpointModelName pluggedInEngine = data.pluggedInEngine pluggedInModelFilename = data.pluggedInModelFilename + pluggedInEndpointModelName = data.pluggedInEndpointModelName + do { + try endpointCredentialStore.deleteAPIKey() + endpointCredentialRevision &+= 1 + } catch { + CotabbyLogger.app.error("Failed to clear endpoint API key during settings reset: \(error.localizedDescription)") + } schedulePauseExpirationIfNeeded() } @@ -353,6 +392,43 @@ final class SuggestionSettingsModel: ObservableObject { store.saveSelectedEngine(engine) } + func setOpenAICompatibleBaseURL(_ baseURL: String) { + guard openAICompatibleBaseURL != baseURL else { return } + openAICompatibleBaseURL = baseURL + store.saveOpenAICompatibleBaseURL(baseURL) + } + + func setOpenAICompatibleModelName(_ modelName: String) { + guard openAICompatibleModelName != modelName else { return } + openAICompatibleModelName = modelName + store.saveOpenAICompatibleModelName(modelName) + } + + func setOpenAICompatibleAPIMode(_ mode: OpenAICompatibleAPIMode) { + guard openAICompatibleAPIMode != mode else { return } + openAICompatibleAPIMode = mode + store.saveOpenAICompatibleAPIMode(mode) + } + + var openAICompatibleConfiguration: OpenAICompatibleEndpointConfiguration { + get throws { + try OpenAICompatibleEndpointConfiguration( + baseURLString: openAICompatibleBaseURL, + modelName: openAICompatibleModelName, + apiMode: openAICompatibleAPIMode + ) + } + } + + func openAICompatibleAPIKey() throws -> String? { + try endpointCredentialStore.readAPIKey() + } + + func saveOpenAICompatibleAPIKey(_ apiKey: String?) throws { + try endpointCredentialStore.saveAPIKey(apiKey) + endpointCredentialRevision &+= 1 + } + func setPowerBasedModelSwitchingEnabled(_ enabled: Bool) { guard isPowerBasedModelSwitchingEnabled != enabled else { return @@ -380,6 +456,12 @@ final class SuggestionSettingsModel: ObservableObject { store.saveBatteryModelFilename(filename) } + func setBatteryEndpointModelName(_ modelName: String) { + guard batteryEndpointModelName != modelName else { return } + batteryEndpointModelName = modelName + store.saveBatteryEndpointModelName(modelName) + } + func setPluggedInEngine(_ engine: SuggestionEngineKind) { guard pluggedInEngine != engine else { return @@ -398,14 +480,28 @@ final class SuggestionSettingsModel: ObservableObject { store.savePluggedInModelFilename(filename) } + func setPluggedInEndpointModelName(_ modelName: String) { + guard pluggedInEndpointModelName != modelName else { return } + pluggedInEndpointModelName = modelName + store.savePluggedInEndpointModelName(modelName) + } + /// The profile applied while on battery, assembled from the stored engine + model filename. var batteryProfile: PowerProfile { - batteryEngine == .appleIntelligence ? .appleIntelligence : .llama(filename: batteryModelFilename) + switch batteryEngine { + case .appleIntelligence: return .appleIntelligence + case .llamaOpenSource: return .llama(filename: batteryModelFilename) + case .openAICompatible: return .openAICompatible(modelName: batteryEndpointModelName) + } } /// The profile applied while plugged in, assembled from the stored engine + model filename. var pluggedInProfile: PowerProfile { - pluggedInEngine == .appleIntelligence ? .appleIntelligence : .llama(filename: pluggedInModelFilename) + switch pluggedInEngine { + case .appleIntelligence: return .appleIntelligence + case .llamaOpenSource: return .llama(filename: pluggedInModelFilename) + case .openAICompatible: return .openAICompatible(modelName: pluggedInEndpointModelName) + } } func setBatteryProfile(_ profile: PowerProfile) { @@ -413,6 +509,9 @@ final class SuggestionSettingsModel: ObservableObject { if case .llama(let filename) = profile { setBatteryModelFilename(filename) } + if case .openAICompatible(let modelName) = profile { + setBatteryEndpointModelName(modelName) + } } func setPluggedInProfile(_ profile: PowerProfile) { @@ -420,24 +519,35 @@ final class SuggestionSettingsModel: ObservableObject { if case .llama(let filename) = profile { setPluggedInModelFilename(filename) } + if case .openAICompatible(let modelName) = profile { + setPluggedInEndpointModelName(modelName) + } } /// Seeds each per-power-source profile from the active engine + model the first time the feature /// is configured, so the pickers default to something valid instead of an empty selection. Only /// seeds a profile still at its pristine default (Open Source with no model chosen), so an /// explicit Apple Intelligence or model choice is never overwritten on a later appearance. - func initializePowerProfiles(currentEngine: SuggestionEngineKind, currentModelFilename: String?) { + func initializePowerProfiles( + currentEngine: SuggestionEngineKind, + currentModelFilename: String?, + currentEndpointModelName: String? = nil + ) { if batteryEngine == .llamaOpenSource, batteryModelFilename.isEmpty { setBatteryEngine(currentEngine) - if let currentModelFilename { + if currentEngine == .llamaOpenSource, let currentModelFilename { setBatteryModelFilename(currentModelFilename) + } else if currentEngine == .openAICompatible, let currentEndpointModelName { + setBatteryEndpointModelName(currentEndpointModelName) } } if pluggedInEngine == .llamaOpenSource, pluggedInModelFilename.isEmpty { setPluggedInEngine(currentEngine) - if let currentModelFilename { + if currentEngine == .llamaOpenSource, let currentModelFilename { setPluggedInModelFilename(currentModelFilename) + } else if currentEngine == .openAICompatible, let currentEndpointModelName { + setPluggedInEndpointModelName(currentEndpointModelName) } } } diff --git a/Cotabby/Services/Runtime/OpenAICompatibleAPIClient.swift b/Cotabby/Services/Runtime/OpenAICompatibleAPIClient.swift new file mode 100644 index 00000000..c8dec55b --- /dev/null +++ b/Cotabby/Services/Runtime/OpenAICompatibleAPIClient.swift @@ -0,0 +1,325 @@ +import Combine +import Foundation + +/// Sampling values shared by the completions and chat-completions request shapes. +nonisolated struct OpenAICompatibleGenerationOptions: Equatable, Sendable { + let maxPredictionTokens: Int + let temperature: Double + let topP: Double +} + +enum OpenAICompatibleClientError: LocalizedError, Equatable { + case invalidResponse + case server(statusCode: Int) + case malformedResponse + case streamError(String) + + var errorDescription: String? { + switch self { + case .invalidResponse: return "The endpoint returned an invalid HTTP response." + case .server(let code): return "The endpoint returned HTTP \(code)." + case .malformedResponse: return "The endpoint returned an invalid OpenAI-compatible response." + case .streamError(let message): return "The endpoint reported an error: \(message)" + } + } +} + +/// Stateless HTTP boundary for OpenAI-compatible local servers. +/// +/// `URLSession` owns socket work off the main actor. This object stays main-actor isolated so the +/// cumulative partial callback can feed Cotabby's UI-facing generation pipeline directly. +@MainActor +final class OpenAICompatibleAPIClient { + private let session: URLSession + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(session: URLSession = .shared) { + self.session = session + } + + func fetchModels( + configuration: OpenAICompatibleEndpointConfiguration, + apiKey: String? + ) async throws -> [OpenAICompatibleModelOption] { + var request = URLRequest(url: configuration.apiURL(path: "models"), timeoutInterval: 10) + applyHeaders(to: &request, apiKey: apiKey) + let (data, response) = try await session.data(for: request) + try Self.validate(response) + let payload = try decoder.decode(ModelListResponse.self, from: data) + return payload.data.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending } + } + + func generate( + configuration: OpenAICompatibleEndpointConfiguration, + apiKey: String?, + prompt: String, + options: OpenAICompatibleGenerationOptions, + onPartialRawText: (@MainActor (String) -> Void)? + ) async throws -> String { + guard !configuration.modelName.isEmpty else { + throw OpenAICompatibleEndpointError.emptyModelName + } + + var request = URLRequest( + url: configuration.apiURL(path: configuration.apiMode.route), + timeoutInterval: 120 + ) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + applyHeaders(to: &request, apiKey: apiKey) + request.httpBody = try requestBody( + mode: configuration.apiMode, + model: configuration.modelName, + prompt: prompt, + options: options + ) + + let (bytes, response) = try await session.bytes(for: request) + try Self.validate(response) + + var accumulated = "" + var sawEvent = false + for try await line in bytes.lines { + try Task.checkCancellation() + switch try OpenAICompatibleSSEDecoder.decode(line, mode: configuration.apiMode) { + case .ignore: + continue + case .done: + sawEvent = true + return accumulated + case .text(let text): + sawEvent = true + accumulated += text + onPartialRawText?(accumulated) + case .error(let message): + throw OpenAICompatibleClientError.streamError(message) + } + } + + guard sawEvent else { throw OpenAICompatibleClientError.malformedResponse } + return accumulated + } + + private func applyHeaders(to request: inout URLRequest, apiKey: String?) { + let key = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !key.isEmpty { + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + } + } + + private func requestBody( + mode: OpenAICompatibleAPIMode, + model: String, + prompt: String, + options: OpenAICompatibleGenerationOptions + ) throws -> Data { + switch mode { + case .completions: + return try encoder.encode(CompletionRequest(model: model, prompt: prompt, options: options)) + case .chatCompletions: + return try encoder.encode(ChatCompletionRequest(model: model, prompt: prompt, options: options)) + } + } + + private static func validate(_ response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { + throw OpenAICompatibleClientError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + throw OpenAICompatibleClientError.server(statusCode: http.statusCode) + } + } +} + +/// Observable model catalog shared by Settings and the Home status card. +/// A refresh generation prevents a slow response for an old URL from replacing a newer catalog. +@MainActor +final class OpenAICompatibleConnectionModel: ObservableObject { + @Published private(set) var models: [OpenAICompatibleModelOption] = [] + @Published private(set) var state: OpenAICompatibleConnectionState = .idle + + private let client: OpenAICompatibleAPIClient + private var refreshGeneration: UInt64 = 0 + + init(client: OpenAICompatibleAPIClient) { + self.client = client + } + + func invalidate() { + refreshGeneration &+= 1 + models = [] + state = .idle + } + + func setFailure(_ message: String) { + refreshGeneration &+= 1 + models = [] + state = .failed(message) + } + + func refresh( + configuration: OpenAICompatibleEndpointConfiguration, + apiKey: String? + ) async { + refreshGeneration &+= 1 + let generation = refreshGeneration + state = .connecting + do { + let fetched = try await client.fetchModels(configuration: configuration, apiKey: apiKey) + guard refreshGeneration == generation else { return } + models = fetched + state = .ready(modelCount: fetched.count) + } catch is CancellationError { + return + } catch { + guard refreshGeneration == generation else { return } + models = [] + state = .failed(error.localizedDescription) + } + } +} + +nonisolated enum OpenAICompatibleSSEEvent: Equatable { + case ignore + case done + case text(String) + case error(String) +} + +/// Pure line decoder for OpenAI's data-only SSE streams. +nonisolated enum OpenAICompatibleSSEDecoder { + static func decode( + _ line: String, + mode: OpenAICompatibleAPIMode, + decoder: JSONDecoder = JSONDecoder() + ) throws -> OpenAICompatibleSSEEvent { + var payload = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !payload.isEmpty else { return .ignore } + if isMetadataLine(payload) { + return .ignore + } + if payload.hasPrefix("data:") { + payload.removeFirst("data:".count) + payload = payload.trimmingCharacters(in: .whitespaces) + } + if payload == "[DONE]" { return .done } + guard let data = payload.data(using: .utf8) else { + throw OpenAICompatibleClientError.malformedResponse + } + return event(from: try streamChunk(from: data, decoder: decoder), mode: mode) + } + + private static func streamChunk(from data: Data, decoder: JSONDecoder) throws -> StreamChunk { + do { + return try decoder.decode(StreamChunk.self, from: data) + } catch { + throw OpenAICompatibleClientError.malformedResponse + } + } + + private static func event( + from chunk: StreamChunk, + mode: OpenAICompatibleAPIMode + ) -> OpenAICompatibleSSEEvent { + if let message = chunk.error?.message, !message.isEmpty { return .error(message) } + guard let choice = chunk.choices?.first else { return .ignore } + let text: String? = switch mode { + case .completions: choice.text + case .chatCompletions: choice.delta?.content ?? choice.message?.content + } + guard let text, !text.isEmpty else { return .ignore } + return .text(text) + } + + private static func isMetadataLine(_ payload: String) -> Bool { + [":", "event:", "id:", "retry:"].contains { payload.hasPrefix($0) } + } +} + +private nonisolated struct ModelListResponse: Decodable { + let data: [OpenAICompatibleModelOption] +} + +private nonisolated struct CompletionRequest: Encodable { + let model: String + let prompt: String + let stream = true + let maxTokens: Int + let temperature: Double + let topP: Double + + init(model: String, prompt: String, options: OpenAICompatibleGenerationOptions) { + self.model = model + self.prompt = prompt + maxTokens = options.maxPredictionTokens + temperature = options.temperature + topP = options.topP + } + + private enum CodingKeys: String, CodingKey { + case model, prompt, stream, temperature + case maxTokens = "max_tokens" + case topP = "top_p" + } +} + +private nonisolated struct ChatCompletionRequest: Encodable { + struct Message: Encodable { + let role: String + let content: String + } + + let model: String + let messages: [Message] + let stream = true + let maxTokens: Int + let temperature: Double + let topP: Double + /// Autocomplete needs visible continuation tokens immediately. Reasoning-capable local models + /// can otherwise spend Cotabby's entire short token budget on an invisible thinking channel. + /// `reasoning_effort` is part of the OpenAI-compatible chat surface and is ignored by models + /// that do not expose reasoning. + let reasoningEffort = "none" + + init(model: String, prompt: String, options: OpenAICompatibleGenerationOptions) { + self.model = model + // The shared prompt is shaped as a base-model continuation and intentionally ends at the + // caret. Chat models otherwise tend to answer by repeating that final line, which Cotabby + // correctly normalizes away and makes the user wait for a suggestion that never appears. + // A short instruction in the same user message preserves the single-message wire contract + // while making the expected output explicit for instruction-tuned endpoint models. + messages = [Message( + role: "user", + content: "Continue the text at the end of the context. Reply with only new continuation " + + "text; do not repeat or quote existing text.\n\n" + prompt + )] + maxTokens = options.maxPredictionTokens + temperature = options.temperature + topP = options.topP + } + + private enum CodingKeys: String, CodingKey { + case model, messages, stream, temperature + case maxTokens = "max_tokens" + case topP = "top_p" + case reasoningEffort = "reasoning_effort" + } +} + +private nonisolated struct StreamChunk: Decodable { + struct Choice: Decodable { + let text: String? + let delta: StreamContent? + let message: StreamContent? + } + struct ErrorPayload: Decodable { let message: String } + + let choices: [Choice]? + let error: ErrorPayload? +} + +private nonisolated struct StreamContent: Decodable { + let content: String? +} diff --git a/Cotabby/Services/Runtime/OpenAICompatibleCredentialStore.swift b/Cotabby/Services/Runtime/OpenAICompatibleCredentialStore.swift new file mode 100644 index 00000000..130a2442 --- /dev/null +++ b/Cotabby/Services/Runtime/OpenAICompatibleCredentialStore.swift @@ -0,0 +1,91 @@ +import Foundation +import Security + +/// Stores the optional endpoint bearer token in the user's Keychain. +/// +/// The settings model owns this service for the app lifetime. The service name includes the bundle +/// identifier so Cotabby and Cotabby Dev do not silently share credentials, and the token is never +/// copied into UserDefaults or diagnostic logs. +@MainActor +final class KeychainOpenAICompatibleCredentialStore: OpenAICompatibleCredentialStoring { + private let service: String + private let account = "api-key" + + init(bundleIdentifier: String = Bundle.main.bundleIdentifier ?? "com.jacobfu.tabby") { + service = "\(bundleIdentifier).openai-compatible-endpoint" + } + + // Keep this explicit while Xcode 26.0–26.3 remain supported. Those toolchains can emit an + // invalid isolated deinitializer when this class is retained through the credential protocol. + deinit {} + + func readAPIKey() 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) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = item as? Data, + let value = String(data: data, encoding: .utf8) else { + throw KeychainCredentialError(status: status) + } + return value + } + + func saveAPIKey(_ apiKey: String?) throws { + let normalized = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalized.isEmpty else { + try deleteAPIKey() + return + } + + let keyData = Data(normalized.utf8) + let identity: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let update: [String: Any] = [kSecValueData as String: keyData] + let updateStatus = SecItemUpdate(identity as CFDictionary, update as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw KeychainCredentialError(status: updateStatus) + } + + var insert = identity + insert[kSecValueData as String] = keyData + insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + let addStatus = SecItemAdd(insert as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainCredentialError(status: addStatus) + } + } + + func deleteAPIKey() 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 KeychainCredentialError(status: status) + } + } +} + +private struct KeychainCredentialError: LocalizedError { + let status: OSStatus + + var errorDescription: String? { + if let message = SecCopyErrorMessageString(status, nil) as String? { + return "The endpoint API key could not be updated: \(message)" + } + return "The endpoint API key could not be updated (Keychain status \(status))." + } +} diff --git a/Cotabby/Services/Runtime/OpenAICompatibleSuggestionEngine.swift b/Cotabby/Services/Runtime/OpenAICompatibleSuggestionEngine.swift new file mode 100644 index 00000000..33e2f703 --- /dev/null +++ b/Cotabby/Services/Runtime/OpenAICompatibleSuggestionEngine.swift @@ -0,0 +1,128 @@ +import Foundation +import Logging + +/// Adapts an OpenAI-compatible SSE stream to Cotabby's backend-independent suggestion contract. +/// The HTTP client owns wire parsing; this type owns sampling, normalization, logging, and error +/// mapping so the router never needs endpoint-specific behavior. +@MainActor +final class OpenAICompatibleSuggestionEngine: SuggestionGenerating { + private let client: OpenAICompatibleAPIClient + private let configurationProvider: @MainActor () throws -> OpenAICompatibleEndpointConfiguration + private let apiKeyProvider: @MainActor () throws -> String? + + init( + client: OpenAICompatibleAPIClient, + configurationProvider: @escaping @MainActor () throws -> OpenAICompatibleEndpointConfiguration, + apiKeyProvider: @escaping @MainActor () throws -> String? + ) { + self.client = client + self.configurationProvider = configurationProvider + self.apiKeyProvider = apiKeyProvider + } + + func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { + try await generateSuggestion(for: request, onPartial: nil) + } + + func generateSuggestion( + for request: SuggestionRequest, + onPartial: (@MainActor (SuggestionResult) -> Void)? + ) async throws -> SuggestionResult { + let metadata: Logger.Metadata = [ + "request_id": .string(request.requestID), + "engine": .string("openai_compatible") + ] + let startTime = Date() + do { + let configuration = try configurationProvider() + let apiKey = try apiKeyProvider() + let partialHandler: (@MainActor (String) -> Void)? + if let onPartial { + partialHandler = { @MainActor cumulativeRaw in + let normalized = SuggestionTextNormalizer.normalizeDetailed(cumulativeRaw, for: request).text + guard !normalized.isEmpty else { return } + onPartial(SuggestionResult( + generation: request.generation, + rawText: cumulativeRaw, + text: normalized, + latency: Date().timeIntervalSince(startTime) + )) + } + } else { + partialHandler = nil + } + + CotabbyLogger.suggestion.debug( + "OpenAI-compatible endpoint generating", + metadata: metadata.merging([ + "model": .string(configuration.modelName), + "api_mode": .string(configuration.apiMode.rawValue), + "prompt_bytes": .stringConvertible(request.prompt.utf8.count), + "max_tokens": .stringConvertible(request.maxPredictionTokens) + ]) { _, new in new } + ) + + let raw = try await client.generate( + configuration: configuration, + apiKey: apiKey, + prompt: request.prompt, + options: OpenAICompatibleGenerationOptions( + maxPredictionTokens: request.maxPredictionTokens, + temperature: request.temperature, + topP: request.topP + ), + onPartialRawText: partialHandler + ) + try Task.checkCancellation() + + let normalization = SuggestionTextNormalizer.normalizeDetailed(raw, for: request) + let latency = Date().timeIntervalSince(startTime) + let latencyMs = Int((latency * 1_000).rounded()) + let suppression = normalization.suppression?.rawValue ?? "none" + CotabbyLogger.suggestion.debug( + "OpenAI-compatible endpoint generated", + metadata: metadata.merging([ + "model": .string(configuration.modelName), + "raw_chars": .stringConvertible(raw.count), + "normalized_chars": .stringConvertible(normalization.text.count), + "suppression_reason": .string(suppression), + "latency_ms": .stringConvertible(latencyMs) + ]) { _, new in new } + ) + CotabbyLogger.llmIO.debug( + "OpenAI-compatible endpoint generation", + metadata: metadata.merging([ + "model": .string(configuration.modelName), + "prompt": .string(request.prompt), + "completion_raw": .string(raw), + "completion_normalized": .string(normalization.text), + "suppression_reason": .string(suppression), + "latency_ms": .stringConvertible(latencyMs) + ]) { _, new in new } + ) + return SuggestionResult( + generation: request.generation, + rawText: raw, + text: normalization.text, + latency: latency, + suppressionReason: normalization.suppression?.rawValue + ) + } catch is CancellationError { + CotabbyLogger.suggestion.debug("Endpoint generation cancelled", metadata: metadata) + throw SuggestionClientError.cancelled + } catch let error as URLError where error.code == .cancelled { + CotabbyLogger.suggestion.debug("Endpoint request cancelled", metadata: metadata) + throw SuggestionClientError.cancelled + } catch let error as OpenAICompatibleEndpointError { + throw SuggestionClientError.unavailable(error.localizedDescription) + } catch { + CotabbyLogger.suggestion.error( + "OpenAI-compatible endpoint failed: \(error.localizedDescription)", + metadata: metadata + ) + throw SuggestionClientError.generationFailed(error.localizedDescription) + } + } + + func resetCachedGenerationContext() async {} +} diff --git a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift index 8d217f6e..05c8124a 100644 --- a/Cotabby/Services/Runtime/SuggestionEngineRouter.swift +++ b/Cotabby/Services/Runtime/SuggestionEngineRouter.swift @@ -10,12 +10,14 @@ final class SuggestionEngineRouter { private let suggestionSettings: SuggestionSettingsModel private let foundationModelEngine: any SuggestionGenerating private let llamaEngine: any SuggestionGenerating + private let openAICompatibleEngine: any SuggestionGenerating private let performanceMetricsStore: PerformanceMetricsStore private let qualityMetricsStore: SuggestionQualityMetricsStore /// Closure that returns the currently selected llama model filename (e.g. `Qwen3-0.6B-Q8_0.gguf`). /// A closure instead of a direct `LlamaRuntimeManager` reference keeps the router from depending /// on the concrete runtime type — useful for tests that want to fake the model label. private let llamaModelNameProvider: @MainActor () -> String? + private let endpointModelNameProvider: @MainActor () -> String? init( suggestionSettings: SuggestionSettingsModel, @@ -23,14 +25,20 @@ final class SuggestionEngineRouter { llamaEngine: any SuggestionGenerating, performanceMetricsStore: PerformanceMetricsStore, qualityMetricsStore: SuggestionQualityMetricsStore, - llamaModelNameProvider: @escaping @MainActor () -> String? + llamaModelNameProvider: @escaping @MainActor () -> String?, + openAICompatibleEngine: (any SuggestionGenerating)? = nil, + endpointModelNameProvider: @escaping @MainActor () -> String? = { nil } ) { self.suggestionSettings = suggestionSettings self.foundationModelEngine = foundationModelEngine self.llamaEngine = llamaEngine + self.openAICompatibleEngine = openAICompatibleEngine ?? UnavailableSuggestionEngine( + message: "Configure a local OpenAI-compatible endpoint in Settings." + ) self.performanceMetricsStore = performanceMetricsStore self.qualityMetricsStore = qualityMetricsStore self.llamaModelNameProvider = llamaModelNameProvider + self.endpointModelNameProvider = endpointModelNameProvider } func generateSuggestion(for request: SuggestionRequest) async throws -> SuggestionResult { @@ -73,6 +81,12 @@ final class SuggestionEngineRouter { recordPerformanceMetric(modelName: llamaModelNameProvider() ?? "Llama", latency: result.latency) recordQualityOutcome(result) return result + case .openAICompatible: + CotabbyLogger.suggestion.debug("Routing to OpenAI-compatible endpoint", metadata: metadata) + let result = try await openAICompatibleEngine.generateSuggestion(for: request, onPartial: onPartial) + recordPerformanceMetric(modelName: endpointModelNameProvider() ?? "Local Endpoint", latency: result.latency) + recordQualityOutcome(result) + return result } } @@ -104,6 +118,8 @@ final class SuggestionEngineRouter { return "apple_intelligence" case .llamaOpenSource: return "llama" + case .openAICompatible: + return "openai_compatible" } } @@ -113,6 +129,7 @@ final class SuggestionEngineRouter { func resetCachedGenerationContext() async { await foundationModelEngine.resetCachedGenerationContext() await llamaEngine.resetCachedGenerationContext() + await openAICompatibleEngine.resetCachedGenerationContext() } /// Forwards the warmup hook only to the currently selected engine. The inactive backend has @@ -124,6 +141,8 @@ final class SuggestionEngineRouter { await foundationModelEngine.prewarm(for: request) case .llamaOpenSource: await llamaEngine.prewarm(for: request) + case .openAICompatible: + await openAICompatibleEngine.prewarm(for: request) } } diff --git a/Cotabby/Support/DebouncePolicy.swift b/Cotabby/Support/DebouncePolicy.swift index 6d2af60a..2f43e24e 100644 --- a/Cotabby/Support/DebouncePolicy.swift +++ b/Cotabby/Support/DebouncePolicy.swift @@ -8,7 +8,26 @@ import Foundation /// to the most recent generation latency makes fast machines snappier and slow machines calmer, /// with no configuration. The configured value remains the fallback until a first latency exists. nonisolated enum DebouncePolicy { - static func milliseconds(lastGenerationLatencyMilliseconds: Int?, fallback: Int) -> Int { + static func milliseconds( + lastGenerationLatencyMilliseconds: Int?, + fallback: Int, + engine: SuggestionEngineKind = .llamaOpenSource + ) -> Int { + // An HTTP endpoint cannot reuse Cotabby's in-process KV cache, and cancelling a request does + // not guarantee the remote server stops its tokenizer immediately. A trailing-edge pause + // therefore saves more time than it costs: it collapses a typing burst into one useful + // request instead of filling Ollama's queue with work that will be discarded. + if engine == .openAICompatible { + guard let last = lastGenerationLatencyMilliseconds, last > 0 else { + return max(fallback, 180) + } + switch last { + case ...300: return 100 + case ...700: return 150 + default: return 220 + } + } + guard let last = lastGenerationLatencyMilliseconds, last > 0 else { return fallback } diff --git a/Cotabby/Support/SettingsAttentionEvaluator.swift b/Cotabby/Support/SettingsAttentionEvaluator.swift index 7a34e9da..4fdacba7 100644 --- a/Cotabby/Support/SettingsAttentionEvaluator.swift +++ b/Cotabby/Support/SettingsAttentionEvaluator.swift @@ -21,6 +21,8 @@ enum SettingsAttentionEvaluator { let foundationModelAvailable: Bool let foundationModelMessage: String let llamaRuntimeFailedReason: String? + let endpointConfigurationError: String? + let endpointConnectionFailedReason: String? } /// Returns the set of categories that should render an attention dot in the sidebar. @@ -40,6 +42,10 @@ enum SettingsAttentionEvaluator { if inputs.llamaRuntimeFailedReason != nil { categories.insert(.engineAndModel) } + case .openAICompatible: + if inputs.endpointConfigurationError != nil || inputs.endpointConnectionFailedReason != nil { + categories.insert(.engineAndModel) + } } return categories @@ -65,6 +71,8 @@ enum SettingsAttentionEvaluator { guard let reason = inputs.llamaRuntimeFailedReason, !reason.isEmpty else { return nil } return reason + case .openAICompatible: + return inputs.endpointConfigurationError ?? inputs.endpointConnectionFailedReason } case .home, .general, .appearance, .emoji, .writing, .context, .shortcuts, .apps, .performance, .about: diff --git a/Cotabby/Support/SuggestionRequestFactory.swift b/Cotabby/Support/SuggestionRequestFactory.swift index 3ede3175..48190318 100644 --- a/Cotabby/Support/SuggestionRequestFactory.swift +++ b/Cotabby/Support/SuggestionRequestFactory.swift @@ -156,6 +156,9 @@ enum SuggestionRequestFactory { case .llamaOpenSource: maxCharacters = configuration.maxPrefixCharacters maxWords = configuration.maxPrefixWords + case .openAICompatible: + maxCharacters = configuration.maxPrefixCharacters + maxWords = configuration.maxPrefixWords } let characterWindow = String(precedingText.suffix(maxCharacters)) @@ -253,6 +256,8 @@ enum SuggestionRequestFactory { return FoundationModelPromptRenderer.promptPreview(for: request) case .llamaOpenSource: return request.prompt + case .openAICompatible: + return request.prompt } } } diff --git a/Cotabby/Support/SuggestionSettingsStore.swift b/Cotabby/Support/SuggestionSettingsStore.swift index 3794df7c..be0d4d28 100644 --- a/Cotabby/Support/SuggestionSettingsStore.swift +++ b/Cotabby/Support/SuggestionSettingsStore.swift @@ -80,6 +80,9 @@ struct SuggestionSettingsStore { private static let ghostTextOpacityDefaultsKey = "cotabbyGhostTextOpacity" private static let ghostTextSizeMultiplierDefaultsKey = "cotabbyGhostTextSizeMultiplier" private static let selectedEngineDefaultsKey = "cotabbySelectedEngine" + private static let openAICompatibleBaseURLDefaultsKey = "cotabbyOpenAICompatibleBaseURL" + private static let openAICompatibleModelNameDefaultsKey = "cotabbyOpenAICompatibleModelName" + private static let openAICompatibleAPIModeDefaultsKey = "cotabbyOpenAICompatibleAPIMode" private static let selectedWordCountPresetDefaultsKey = "cotabbySelectedWordCountPreset" private static let usingCustomWordCountRangeDefaultsKey = "cotabbyUsingCustomWordCountRange" private static let customWordCountLowWordsDefaultsKey = "cotabbyCustomWordCountLowWords" @@ -137,8 +140,10 @@ struct SuggestionSettingsStore { private static let powerModelSwitchingEnabledDefaultsKey = "cotabbyPowerBasedModelSwitchingEnabled" private static let batteryEngineDefaultsKey = "cotabbyBatteryEngine" private static let batteryModelFilenameDefaultsKey = "cotabbyBatteryModelFilename" + private static let batteryEndpointModelNameDefaultsKey = "cotabbyBatteryEndpointModelName" private static let pluggedInEngineDefaultsKey = "cotabbyPluggedInEngine" private static let pluggedInModelFilenameDefaultsKey = "cotabbyPluggedInModelFilename" + private static let pluggedInEndpointModelNameDefaultsKey = "cotabbyPluggedInEndpointModelName" /// Every persisted-preference key this store owns, in one place so `resetToDefaults` clears the /// full set. App state that is deliberately *not* a preference lives under other keys and is @@ -159,6 +164,9 @@ struct SuggestionSettingsStore { ghostTextOpacityDefaultsKey, ghostTextSizeMultiplierDefaultsKey, selectedEngineDefaultsKey, + openAICompatibleBaseURLDefaultsKey, + openAICompatibleModelNameDefaultsKey, + openAICompatibleAPIModeDefaultsKey, selectedWordCountPresetDefaultsKey, usingCustomWordCountRangeDefaultsKey, customWordCountLowWordsDefaultsKey, @@ -201,8 +209,10 @@ struct SuggestionSettingsStore { powerModelSwitchingEnabledDefaultsKey, batteryEngineDefaultsKey, batteryModelFilenameDefaultsKey, + batteryEndpointModelNameDefaultsKey, pluggedInEngineDefaultsKey, - pluggedInModelFilenameDefaultsKey + pluggedInModelFilenameDefaultsKey, + pluggedInEndpointModelNameDefaultsKey ] // MARK: - Load @@ -250,6 +260,16 @@ struct SuggestionSettingsStore { .string(forKey: Self.selectedEngineDefaultsKey) .flatMap(SuggestionEngineKind.init(rawValue:)) ?? .llamaOpenSource + let resolvedOpenAICompatibleBaseURL = userDefaults + .string(forKey: Self.openAICompatibleBaseURLDefaultsKey) + ?? OpenAICompatibleEndpointConfiguration.defaultBaseURLString + let resolvedOpenAICompatibleModelName = userDefaults + .string(forKey: Self.openAICompatibleModelNameDefaultsKey)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let resolvedOpenAICompatibleAPIMode = userDefaults + .string(forKey: Self.openAICompatibleAPIModeDefaultsKey) + .flatMap(OpenAICompatibleAPIMode.init(rawValue:)) + ?? .chatCompletions let resolvedWordCountPreset: SuggestionWordCountPreset = { let storedRaw = userDefaults.string(forKey: Self.selectedWordCountPresetDefaultsKey) // Migrate the retired "3-7" raw value to its replacement "4-7" so users who picked @@ -447,9 +467,13 @@ struct SuggestionSettingsStore { let resolvedBatteryEngine = userDefaults.string(forKey: Self.batteryEngineDefaultsKey) .flatMap(SuggestionEngineKind.init(rawValue:)) ?? .llamaOpenSource let resolvedBatteryModelFilename = userDefaults.string(forKey: Self.batteryModelFilenameDefaultsKey) ?? "" + let resolvedBatteryEndpointModelName = userDefaults + .string(forKey: Self.batteryEndpointModelNameDefaultsKey) ?? "" let resolvedPluggedInEngine = userDefaults.string(forKey: Self.pluggedInEngineDefaultsKey) .flatMap(SuggestionEngineKind.init(rawValue:)) ?? .llamaOpenSource let resolvedPluggedInModelFilename = userDefaults.string(forKey: Self.pluggedInModelFilenameDefaultsKey) ?? "" + let resolvedPluggedInEndpointModelName = userDefaults + .string(forKey: Self.pluggedInEndpointModelNameDefaultsKey) ?? "" let data = SuggestionSettingsData( isGloballyEnabled: resolvedGloballyEnabled, @@ -462,6 +486,9 @@ struct SuggestionSettingsStore { ghostTextOpacity: resolvedGhostTextOpacity, ghostTextSizeMultiplier: resolvedGhostTextSizeMultiplier, selectedEngine: resolvedEngine, + openAICompatibleBaseURL: resolvedOpenAICompatibleBaseURL, + openAICompatibleModelName: resolvedOpenAICompatibleModelName, + openAICompatibleAPIMode: resolvedOpenAICompatibleAPIMode, selectedWordCountPreset: resolvedWordCountPreset, isUsingCustomWordCountRange: resolvedUsingCustomWordCountRange, customWordCountLowWords: resolvedCustomRange.lowWords, @@ -506,8 +533,10 @@ struct SuggestionSettingsStore { isPowerBasedModelSwitchingEnabled: resolvedPowerBasedModelSwitchingEnabled, batteryEngine: resolvedBatteryEngine, batteryModelFilename: resolvedBatteryModelFilename, + batteryEndpointModelName: resolvedBatteryEndpointModelName, pluggedInEngine: resolvedPluggedInEngine, - pluggedInModelFilename: resolvedPluggedInModelFilename + pluggedInModelFilename: resolvedPluggedInModelFilename, + pluggedInEndpointModelName: resolvedPluggedInEndpointModelName ) // Unconditional write-back so the resolved (possibly migrated or default-capped) values are @@ -522,6 +551,9 @@ struct SuggestionSettingsStore { saveGhostTextOpacity(data.ghostTextOpacity) saveGhostTextSizeMultiplier(data.ghostTextSizeMultiplier) saveSelectedEngine(data.selectedEngine) + saveOpenAICompatibleBaseURL(data.openAICompatibleBaseURL) + saveOpenAICompatibleModelName(data.openAICompatibleModelName) + saveOpenAICompatibleAPIMode(data.openAICompatibleAPIMode) saveSelectedWordCountPreset(data.selectedWordCountPreset) saveUsingCustomWordCountRange(data.isUsingCustomWordCountRange) saveCustomWordCountRange(low: data.customWordCountLowWords, high: data.customWordCountHighWords) @@ -571,8 +603,10 @@ struct SuggestionSettingsStore { savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled) saveBatteryEngine(data.batteryEngine) saveBatteryModelFilename(data.batteryModelFilename) + saveBatteryEndpointModelName(data.batteryEndpointModelName) savePluggedInEngine(data.pluggedInEngine) savePluggedInModelFilename(data.pluggedInModelFilename) + savePluggedInEndpointModelName(data.pluggedInEndpointModelName) // The custom indicator icon feature was removed; scrub any previously-persisted PNG so // users who picked one in an older build get the default cat icon back automatically. @@ -658,6 +692,18 @@ struct SuggestionSettingsStore { userDefaults.set(engine.rawValue, forKey: Self.selectedEngineDefaultsKey) } + func saveOpenAICompatibleBaseURL(_ baseURL: String) { + userDefaults.set(baseURL, forKey: Self.openAICompatibleBaseURLDefaultsKey) + } + + func saveOpenAICompatibleModelName(_ modelName: String) { + userDefaults.set(modelName, forKey: Self.openAICompatibleModelNameDefaultsKey) + } + + func saveOpenAICompatibleAPIMode(_ mode: OpenAICompatibleAPIMode) { + userDefaults.set(mode.rawValue, forKey: Self.openAICompatibleAPIModeDefaultsKey) + } + func savePowerBasedModelSwitchingEnabled(_ enabled: Bool) { userDefaults.set(enabled, forKey: Self.powerModelSwitchingEnabledDefaultsKey) } @@ -670,6 +716,10 @@ struct SuggestionSettingsStore { userDefaults.set(filename, forKey: Self.batteryModelFilenameDefaultsKey) } + func saveBatteryEndpointModelName(_ modelName: String) { + userDefaults.set(modelName, forKey: Self.batteryEndpointModelNameDefaultsKey) + } + func savePluggedInEngine(_ engine: SuggestionEngineKind) { userDefaults.set(engine.rawValue, forKey: Self.pluggedInEngineDefaultsKey) } @@ -678,6 +728,10 @@ struct SuggestionSettingsStore { userDefaults.set(filename, forKey: Self.pluggedInModelFilenameDefaultsKey) } + func savePluggedInEndpointModelName(_ modelName: String) { + userDefaults.set(modelName, forKey: Self.pluggedInEndpointModelNameDefaultsKey) + } + func saveSelectedWordCountPreset(_ preset: SuggestionWordCountPreset) { userDefaults.set(preset.rawValue, forKey: Self.selectedWordCountPresetDefaultsKey) } diff --git a/Cotabby/UI/MenuBarView.swift b/Cotabby/UI/MenuBarView.swift index 17974222..9b306fe0 100644 --- a/Cotabby/UI/MenuBarView.swift +++ b/Cotabby/UI/MenuBarView.swift @@ -387,9 +387,15 @@ struct MenuBarView: View { suggestionSettings.selectEngine(engine) return } - let profile: PowerProfile = engine == .appleIntelligence - ? .appleIntelligence - : .llama(filename: runtimeModel.selectedModelFilename ?? "") + let profile: PowerProfile + switch engine { + case .appleIntelligence: + profile = .appleIntelligence + case .llamaOpenSource: + profile = .llama(filename: runtimeModel.selectedModelFilename ?? "") + case .openAICompatible: + profile = .openAICompatible(modelName: suggestionSettings.openAICompatibleModelName) + } applyProfileForCurrentPowerSource(profile) } ) diff --git a/Cotabby/UI/Onboarding/WelcomeTemplateStepView.swift b/Cotabby/UI/Onboarding/WelcomeTemplateStepView.swift index 106b2b46..1772fbb5 100644 --- a/Cotabby/UI/Onboarding/WelcomeTemplateStepView.swift +++ b/Cotabby/UI/Onboarding/WelcomeTemplateStepView.swift @@ -331,6 +331,8 @@ private struct TemplateCard: View { case .llamaOpenSource: guard let model = plan.modelToDownload else { return "Local model" } return "\(model.displayName) · \(model.approximateSizeLabel) download" + case .openAICompatible: + return "Local endpoint · configure later in Settings" } } diff --git a/Cotabby/UI/Onboarding/WelcomeView.swift b/Cotabby/UI/Onboarding/WelcomeView.swift index 3240f3cf..bc8db4ff 100644 --- a/Cotabby/UI/Onboarding/WelcomeView.swift +++ b/Cotabby/UI/Onboarding/WelcomeView.swift @@ -517,6 +517,8 @@ extension WelcomeView { return false } return state == .downloaded || state.isDownloading + case .openAICompatible: + return true } } diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift index 818e64d7..afa44009 100644 --- a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift @@ -8,12 +8,15 @@ import SwiftUI /// settings sidebar flat and gives users a single place to manage everything model-related. struct EngineAndModelPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel + @ObservedObject var openAICompatibleConnectionModel: OpenAICompatibleConnectionModel @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService @ObservedObject var runtimeModel: RuntimeBootstrapModel @ObservedObject var modelDownloadManager: ModelDownloadManager @ObservedObject var huggingFaceSearchService: HuggingFaceSearchService @State private var pendingDeletionModel: RuntimeModelOption? + @State private var endpointAPIKeyDraft = "" + @State private var endpointCredentialError: String? /// The LM Studio models directory if it exists, probed once in `onAppear` so the filesystem /// `fileExists` check never runs on the SwiftUI render path. Nil disables the LM Studio toggle. @State private var lmStudioModelsURL: URL? @@ -33,8 +36,8 @@ struct EngineAndModelPaneView: View { SettingsRowLabel( title: "Engine", description: "Apple Intelligence runs on-device using macOS's built-in model " + - "(newer Apple Silicon Macs only). Open Source runs a model file you download " + - "and pick below.", + "(newer Apple Silicon Macs only). Open Source runs a downloaded model file. " + + "Local Endpoint connects to an OpenAI-compatible server you manage.", systemImage: "cpu" ) } @@ -49,6 +52,8 @@ struct EngineAndModelPaneView: View { appleIntelligenceSections case .llamaOpenSource: openSourceSections + case .openAICompatible: + openAICompatibleSections } } .onAppear { @@ -56,7 +61,8 @@ struct EngineAndModelPaneView: View { suggestionSettings.initializePowerProfiles( currentEngine: suggestionSettings.selectedEngine, - currentModelFilename: runtimeModel.selectedModelFilename + currentModelFilename: runtimeModel.selectedModelFilename, + currentEndpointModelName: suggestionSettings.openAICompatibleModelName ) lmStudioModelsURL = BundledRuntimeLocator.lmStudioModelsDirectoryIfAvailable() @@ -67,6 +73,17 @@ struct EngineAndModelPaneView: View { if lmStudioModelsURL == nil, lmStudioSourceEnabled { lmStudioSourceEnabled = false } + + loadEndpointAPIKey() + if suggestionSettings.selectedEngine == .openAICompatible, + openAICompatibleConnectionModel.state == .idle { + refreshEndpointModels() + } + } + .onChange(of: suggestionSettings.selectedEngine) { _, engine in + if engine == .openAICompatible, openAICompatibleConnectionModel.state == .idle { + refreshEndpointModels() + } } .alert( "Delete Model?", @@ -138,6 +155,10 @@ struct EngineAndModelPaneView: View { ForEach(runtimeModel.availableModels) { model in Text(model.displayName).tag(PowerProfile.llama(filename: model.filename)) } + + ForEach(endpointPowerModels, id: \.self) { modelName in + Text("Endpoint · \(modelName)").tag(PowerProfile.openAICompatible(modelName: modelName)) + } } label: { SettingsRowLabel( title: title, @@ -168,10 +189,22 @@ struct EngineAndModelPaneView: View { if case .llama(let filename) = profile, filename.isEmpty { return .llama(filename: runtimeModel.selectedModelFilename ?? "") } + if case .openAICompatible(let modelName) = profile, modelName.isEmpty { + return .openAICompatible(modelName: suggestionSettings.openAICompatibleModelName) + } return profile } + private var endpointPowerModels: [String] { + var names = openAICompatibleConnectionModel.models.map(\.id) + let configured = suggestionSettings.openAICompatibleModelName + if !configured.isEmpty, !names.contains(configured) { + names.append(configured) + } + return names + } + // MARK: - Apple Intelligence @ViewBuilder @@ -314,6 +347,148 @@ struct EngineAndModelPaneView: View { } } + // MARK: - OpenAI-compatible endpoint + + /// Endpoint controls stay inside the unified engine pane. The connection model owns network + /// state, the settings model owns durable non-secret values, and the API key draft only lives + /// for this view's lifetime before an explicit Connect saves it to Keychain. + @ViewBuilder + private var openAICompatibleSections: some View { + Section("Connection") { + VStack(alignment: .leading, spacing: 10) { + SettingsRowLabel( + title: "Base URL", + description: "The OpenAI-compatible /v1 base URL. Ollama uses " + + "http://127.0.0.1:11434/v1 by default.", + systemImage: "network" + ) + + TextField( + OpenAICompatibleEndpointConfiguration.defaultBaseURLString, + text: endpointBaseURLBinding + ) + .textFieldStyle(.roundedBorder) + .onSubmit(refreshEndpointModels) + + HStack(spacing: 8) { + Text("Press Return or connect to check this endpoint.") + .font(.caption) + .foregroundStyle(.secondary) + + Spacer(minLength: 8) + + Button("Reset to Default", systemImage: "arrow.counterclockwise") { + resetEndpointBaseURL() + } + .disabled( + suggestionSettings.openAICompatibleBaseURL + == OpenAICompatibleEndpointConfiguration.defaultBaseURLString + ) + + Button( + endpointConnectButtonTitle, + action: refreshEndpointModels + ) + .buttonStyle(.borderedProminent) + .disabled(openAICompatibleConnectionModel.state == .connecting) + } + } + .settingsItem(.endpointBaseURL) + + Picker(selection: endpointAPIModeBinding) { + ForEach(OpenAICompatibleAPIMode.allCases) { mode in + Text(mode.displayLabel).tag(mode) + } + } label: { + SettingsRowLabel( + title: "Request Mode", + description: "Use Chat Completions for instruction-tuned models, or Completions " + + "for base models that continue a raw prompt.", + systemImage: "arrow.left.arrow.right" + ) + } + .pickerStyle(.menu) + .settingsItem(.endpointAPIMode) + + LabeledContent { + HStack(spacing: 8) { + SecureField("Optional", text: $endpointAPIKeyDraft) + .textFieldStyle(.roundedBorder) + .frame(width: 240) + + if !endpointAPIKeyDraft.isEmpty { + Button("Clear") { + endpointAPIKeyDraft = "" + do { + try suggestionSettings.saveOpenAICompatibleAPIKey(nil) + endpointCredentialError = nil + } catch { + endpointCredentialError = error.localizedDescription + } + } + } + } + } label: { + SettingsRowLabel( + title: "API Key", + description: "Optional bearer token stored in Keychain. Ollama does not require one.", + systemImage: "key" + ) + } + .settingsItem(.endpointAPIKey) + + endpointConnectionSummary + .settingsItem(.endpointStatus) + + if let warning = endpointPrivacyWarning { + SettingsCalloutView(callout: SettingsPaneCallout(tone: .warning, message: warning)) + } + if let endpointCredentialError { + SettingsCalloutView( + callout: SettingsPaneCallout(tone: .warning, message: endpointCredentialError) + ) + } + } + + Section("Model") { + if !openAICompatibleConnectionModel.models.isEmpty { + Picker(selection: endpointModelBinding) { + ForEach(openAICompatibleConnectionModel.models) { model in + Text(model.id).tag(model.id) + } + if !suggestionSettings.openAICompatibleModelName.isEmpty, + !openAICompatibleConnectionModel.models.contains(where: { + $0.id == suggestionSettings.openAICompatibleModelName + }) { + Text(suggestionSettings.openAICompatibleModelName) + .tag(suggestionSettings.openAICompatibleModelName) + } + } label: { + SettingsRowLabel( + title: "Available Models", + description: "Models returned by the server's /v1/models endpoint.", + systemImage: "shippingbox" + ) + } + .pickerStyle(.menu) + } + + LabeledContent { + TextField("Model identifier", text: endpointModelBinding) + .textFieldStyle(.roundedBorder) + .frame(width: 300) + } label: { + SettingsRowLabel( + title: "Model Name", + description: "The exact model identifier sent with every request. You can enter " + + "one manually when model discovery is unavailable.", + systemImage: "text.cursor" + ) + } + .settingsItem(.endpointModel) + } + } + @ViewBuilder private func installedModelRow(_ model: RuntimeModelOption) -> some View { HStack { @@ -360,6 +535,20 @@ struct EngineAndModelPaneView: View { case .llamaOpenSource: guard case .failed(let detail) = runtimeModel.state else { return nil } return SettingsPaneCallout(tone: .warning, message: detail) + case .openAICompatible: + do { + let configuration = try suggestionSettings.openAICompatibleConfiguration + guard !configuration.modelName.isEmpty else { + return SettingsPaneCallout( + tone: .warning, + message: OpenAICompatibleEndpointError.emptyModelName.localizedDescription + ) + } + } catch { + return SettingsPaneCallout(tone: .warning, message: error.localizedDescription) + } + guard let detail = openAICompatibleConnectionModel.state.failureDetail else { return nil } + return SettingsPaneCallout(tone: .warning, message: detail) } } @@ -385,6 +574,114 @@ struct EngineAndModelPaneView: View { ) } + private var endpointBaseURLBinding: Binding { + Binding( + get: { suggestionSettings.openAICompatibleBaseURL }, + set: { value in + suggestionSettings.setOpenAICompatibleBaseURL(value) + openAICompatibleConnectionModel.invalidate() + } + ) + } + + private var endpointModelBinding: Binding { + Binding( + get: { suggestionSettings.openAICompatibleModelName }, + set: { suggestionSettings.setOpenAICompatibleModelName($0) } + ) + } + + private var endpointAPIModeBinding: Binding { + Binding( + get: { suggestionSettings.openAICompatibleAPIMode }, + set: { suggestionSettings.setOpenAICompatibleAPIMode($0) } + ) + } + + private var endpointPrivacyWarning: String? { + (try? suggestionSettings.openAICompatibleConfiguration)?.privacyWarning + } + + /// The status card keeps the configured server identity visible without making the editable + /// text field carry two jobs. A user can now distinguish "this is what I typed" from "this is + /// the endpoint Cotabby most recently tried to reach" at a glance. + private var endpointConnectionSummary: some View { + HStack(spacing: 12) { + Group { + if openAICompatibleConnectionModel.state == .connecting { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: endpointConnectionSymbol) + .foregroundStyle(endpointConnectionColor) + } + } + .frame(width: 18) + + VStack(alignment: .leading, spacing: 3) { + Text(openAICompatibleConnectionModel.state.summary) + .font(.callout.weight(.medium)) + .foregroundStyle(endpointConnectionColor) + + Text(endpointDisplayURL) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + .help(endpointDisplayURL) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.secondary.opacity(0.08)) + } + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.secondary.opacity(0.16), lineWidth: 1) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Server status") + .accessibilityValue("\(openAICompatibleConnectionModel.state.summary), \(endpointDisplayURL)") + } + + private var endpointConnectButtonTitle: String { + if case .ready = openAICompatibleConnectionModel.state { + return "Refresh" + } + return "Connect" + } + + private var endpointConnectionSymbol: String { + switch openAICompatibleConnectionModel.state { + case .idle: return "circle.dashed" + case .connecting: return "circle.dashed" + case .ready: return "checkmark.circle.fill" + case .failed: return "exclamationmark.triangle.fill" + } + } + + private var endpointConnectionColor: Color { + switch openAICompatibleConnectionModel.state { + case .idle, .connecting: return .secondary + case .ready: return .green + case .failed: return .orange + } + } + + private var endpointDisplayURL: String { + if let configuration = try? suggestionSettings.openAICompatibleConfiguration { + return configuration.baseURL.absoluteString + } + let enteredURL = suggestionSettings.openAICompatibleBaseURL + .trimmingCharacters(in: .whitespacesAndNewlines) + return enteredURL.isEmpty ? "No endpoint configured" : enteredURL + } + private var pendingDeletionAlertBinding: Binding { Binding( get: { pendingDeletionModel != nil }, @@ -406,4 +703,45 @@ struct EngineAndModelPaneView: View { modelDownloadManager.refreshModelStates() runtimeModel.refreshAvailableModels() } + + /// Reset is intentionally local to the address. The selected model and Keychain credential + /// remain untouched because they may still be valid for the default Ollama server. Invalidating + /// discovery makes the status honest until the user explicitly connects again. + private func resetEndpointBaseURL() { + suggestionSettings.setOpenAICompatibleBaseURL( + OpenAICompatibleEndpointConfiguration.defaultBaseURLString + ) + openAICompatibleConnectionModel.invalidate() + } + + private func loadEndpointAPIKey() { + do { + endpointAPIKeyDraft = try suggestionSettings.openAICompatibleAPIKey() ?? "" + endpointCredentialError = nil + } catch { + endpointCredentialError = error.localizedDescription + } + } + + private func refreshEndpointModels() { + Task { + do { + try suggestionSettings.saveOpenAICompatibleAPIKey(endpointAPIKeyDraft) + endpointCredentialError = nil + let configuration = try suggestionSettings.openAICompatibleConfiguration + await openAICompatibleConnectionModel.refresh( + configuration: configuration, + apiKey: endpointAPIKeyDraft + ) + if suggestionSettings.openAICompatibleModelName.isEmpty, + openAICompatibleConnectionModel.models.count == 1, + let onlyModel = openAICompatibleConnectionModel.models.first { + suggestionSettings.setOpenAICompatibleModelName(onlyModel.id) + } + } catch { + endpointCredentialError = error.localizedDescription + openAICompatibleConnectionModel.setFailure(error.localizedDescription) + } + } + } } diff --git a/Cotabby/UI/Settings/Panes/HomePaneView.swift b/Cotabby/UI/Settings/Panes/HomePaneView.swift index 17fb2375..5c54af98 100644 --- a/Cotabby/UI/Settings/Panes/HomePaneView.swift +++ b/Cotabby/UI/Settings/Panes/HomePaneView.swift @@ -17,6 +17,7 @@ struct HomePaneView: View { @ObservedObject var permissionManager: PermissionManager @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService @ObservedObject var runtimeModel: RuntimeBootstrapModel + @ObservedObject var openAICompatibleConnectionModel: OpenAICompatibleConnectionModel let attentionCategories: Set @State private var query = "" @@ -325,6 +326,13 @@ struct HomePaneView: View { let selected = runtimeModel.availableModels .first { $0.filename == runtimeModel.selectedModelFilename } return selected?.displayName ?? "No model selected" + case .openAICompatible: + if openAICompatibleConnectionModel.state.failureDetail != nil { + return "Connection failed" + } + return suggestionSettings.openAICompatibleModelName.isEmpty + ? "No model selected" + : suggestionSettings.openAICompatibleModelName } } diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index a45ce0ae..fd1ce87e 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -17,6 +17,7 @@ struct SettingsContainerView: View { @ObservedObject var permissionManager: PermissionManager let permissionGuidanceController: PermissionGuidanceController @ObservedObject var suggestionSettings: SuggestionSettingsModel + @ObservedObject var openAICompatibleConnectionModel: OpenAICompatibleConnectionModel @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService @ObservedObject var runtimeModel: RuntimeBootstrapModel @ObservedObject var modelDownloadManager: ModelDownloadManager @@ -98,7 +99,9 @@ struct SettingsContainerView: View { selectedEngine: suggestionSettings.selectedEngine, foundationModelAvailable: foundationModelAvailabilityService.isAvailable, foundationModelMessage: foundationModelAvailabilityService.userVisibleMessage, - llamaRuntimeFailedReason: runtimeModel.state.failureDetail + llamaRuntimeFailedReason: runtimeModel.state.failureDetail, + endpointConfigurationError: endpointConfigurationError, + endpointConnectionFailedReason: openAICompatibleConnectionModel.state.failureDetail ) ) } @@ -113,6 +116,7 @@ struct SettingsContainerView: View { permissionManager: permissionManager, foundationModelAvailabilityService: foundationModelAvailabilityService, runtimeModel: runtimeModel, + openAICompatibleConnectionModel: openAICompatibleConnectionModel, attentionCategories: attentionCategories ) case .general: @@ -131,6 +135,7 @@ struct SettingsContainerView: View { case .engineAndModel: EngineAndModelPaneView( suggestionSettings: suggestionSettings, + openAICompatibleConnectionModel: openAICompatibleConnectionModel, foundationModelAvailabilityService: foundationModelAvailabilityService, runtimeModel: runtimeModel, modelDownloadManager: modelDownloadManager, @@ -161,6 +166,17 @@ struct SettingsContainerView: View { } } + private var endpointConfigurationError: String? { + do { + let configuration = try suggestionSettings.openAICompatibleConfiguration + return configuration.modelName.isEmpty + ? OpenAICompatibleEndpointError.emptyModelName.localizedDescription + : nil + } catch { + return error.localizedDescription + } + } + private static func restoreSelection(from rawValue: String) -> SettingsCategory { if let category = SettingsCategory(rawValue: rawValue) { return category diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index f8df55c3..35e1e39c 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -64,6 +64,11 @@ enum SettingsItem: String, CaseIterable, Identifiable { case huggingFaceBrowser case modelsFolder case lmStudio + case endpointBaseURL + case endpointAPIMode + case endpointAPIKey + case endpointStatus + case endpointModel // Shortcuts case acceptanceMode case acceptWord @@ -139,6 +144,11 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .huggingFaceBrowser: return "Hugging Face Model Browser" case .modelsFolder: return "Models Folder" case .lmStudio: return "LM Studio Models" + case .endpointBaseURL: return "Endpoint Base URL" + case .endpointAPIMode: return "Endpoint Request Mode" + case .endpointAPIKey: return "Endpoint API Key" + case .endpointStatus: return "Endpoint Status" + case .endpointModel: return "Endpoint Model" case .acceptanceMode: return "Acceptance Mode" case .acceptWord: return "Accept Word" case .acceptEntireSuggestion: return "Accept Entire Suggestion" @@ -209,6 +219,11 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .huggingFaceBrowser: return "magnifyingglass" case .modelsFolder: return "folder" case .lmStudio: return "square.stack.3d.up" + case .endpointBaseURL: return "network" + case .endpointAPIMode: return "arrow.left.arrow.right" + case .endpointAPIKey: return "key" + case .endpointStatus: return "info.circle" + case .endpointModel: return "shippingbox" case .acceptanceMode: return "textformat.abc" case .acceptWord: return "arrow.right.to.line" case .acceptEntireSuggestion: return "text.insert" @@ -249,7 +264,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { return .context case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel, .powerBasedModelSwitching, .batteryModel, .pluggedInModel, - .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio: + .downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio, + .endpointBaseURL, .endpointAPIMode, .endpointAPIKey, .endpointStatus, .endpointModel: return .engineAndModel case .acceptanceMode, .acceptWord, .acceptEntireSuggestion, .toggleTabby: return .shortcuts @@ -304,7 +320,7 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .automaticallyFixTypos: return "Replace a misspelled word right after you press Space." case .extendedContext: return "A glossary or notes sent with every suggestion." case .contextLivePreview: return "A real field that exercises the full pipeline." - case .engine: return "Apple Intelligence or an open-source local model." + case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint." case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." case .modelStatus: return "Whether the local model is loaded and ready." case .selectedModel: return "Which downloaded model generates suggestions." @@ -315,6 +331,11 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .huggingFaceBrowser: return "Search Hugging Face for GGUF models." case .modelsFolder: return "Where downloaded model files live on this Mac." case .lmStudio: return "Also load models from your LM Studio library." + case .endpointBaseURL: return "Where the OpenAI-compatible server accepts /v1 requests." + case .endpointAPIMode: return "Choose Completions or Chat Completions for this model." + case .endpointAPIKey: return "Optional bearer token stored securely in Keychain." + case .endpointStatus: return "Whether Cotabby can reach the server and list its models." + case .endpointModel: return "The model identifier sent to the configured endpoint." case .acceptanceMode: return "Whether the accept key takes a word or a phrase." case .acceptWord: return "The key that inserts the next word." case .acceptEntireSuggestion: return "The key that inserts the whole suggestion." @@ -452,7 +473,8 @@ enum SettingsItem: String, CaseIterable, Identifiable { "demo", "try it", "test field"] case .engine: return ["engine", "apple intelligence", "open source", "llama", "backend", - "provider", "runtime", "foundation models", "oss", "local", + "provider", "runtime", "foundation models", "oss", "local", "endpoint", + "ollama", "openai compatible", "lm studio", "vllm", "on-device", "model engine"] case .appleIntelligenceAvailability: return ["apple intelligence", "availability", "available", "supported", @@ -485,6 +507,16 @@ enum SettingsItem: String, CaseIterable, Identifiable { case .lmStudio: return ["lm studio", "lmstudio", "import", "library", "external", "source", "third party"] + case .endpointBaseURL: + return ["endpoint", "url", "server", "host", "ollama", "localhost", "openai", "v1"] + case .endpointAPIMode: + return ["completion", "chat completion", "api", "mode", "request", "openai"] + case .endpointAPIKey: + return ["api key", "token", "bearer", "credential", "keychain", "authentication"] + case .endpointStatus: + return ["connect", "connection", "server", "status", "refresh", "health", "models"] + case .endpointModel: + return ["model", "identifier", "ollama", "lm studio", "vllm", "endpoint"] case .acceptanceMode: return ["acceptance", "word", "phrase", "mode", "tap", "hold", "behavior", "how to accept"] diff --git a/CotabbyInfo.plist b/CotabbyInfo.plist index ff492ae0..3d968dca 100644 --- a/CotabbyInfo.plist +++ b/CotabbyInfo.plist @@ -24,6 +24,11 @@ NSAccessibilityUsageDescription Cotabby needs Accessibility access to read and insert text in other apps. + NSAppTransportSecurity + + NSAllowsLocalNetworking + + SUAllowsAutomaticUpdates SUAutomaticallyUpdate diff --git a/CotabbyTests/DebouncePolicyTests.swift b/CotabbyTests/DebouncePolicyTests.swift index bcb1afbc..1ce527b9 100644 --- a/CotabbyTests/DebouncePolicyTests.swift +++ b/CotabbyTests/DebouncePolicyTests.swift @@ -21,4 +21,39 @@ final class DebouncePolicyTests: XCTestCase { XCTAssertEqual(DebouncePolicy.milliseconds(lastGenerationLatencyMilliseconds: 141, fallback: 20), 55) XCTAssertEqual(DebouncePolicy.milliseconds(lastGenerationLatencyMilliseconds: 900, fallback: 20), 55) } + + func testEndpointUsesTrailingDebounceToCollapseTypingBursts() { + XCTAssertEqual( + DebouncePolicy.milliseconds( + lastGenerationLatencyMilliseconds: nil, + fallback: 20, + engine: .openAICompatible + ), + 180 + ) + XCTAssertEqual( + DebouncePolicy.milliseconds( + lastGenerationLatencyMilliseconds: 250, + fallback: 20, + engine: .openAICompatible + ), + 100 + ) + XCTAssertEqual( + DebouncePolicy.milliseconds( + lastGenerationLatencyMilliseconds: 600, + fallback: 20, + engine: .openAICompatible + ), + 150 + ) + XCTAssertEqual( + DebouncePolicy.milliseconds( + lastGenerationLatencyMilliseconds: 1_000, + fallback: 20, + engine: .openAICompatible + ), + 220 + ) + } } diff --git a/CotabbyTests/OpenAICompatibleAPIClientTests.swift b/CotabbyTests/OpenAICompatibleAPIClientTests.swift new file mode 100644 index 00000000..c7fa88c8 --- /dev/null +++ b/CotabbyTests/OpenAICompatibleAPIClientTests.swift @@ -0,0 +1,304 @@ +import Foundation +import XCTest +@testable import Cotabby + +/// Locks the generic endpoint wire contract without requiring a local server. URL policy and SSE +/// parsing are pure; URLProtocol stubs exercise the real URLSession request paths and headers. +@MainActor +final class OpenAICompatibleAPIClientTests: XCTestCase { + override func tearDown() { + EndpointStubURLProtocol.handler = nil + EndpointStubURLProtocol.holdRequests = false + EndpointStubURLProtocol.onStart = nil + EndpointStubURLProtocol.onStop = nil + super.tearDown() + } + + func test_configuration_normalizesRootURLAndClassifiesHosts() throws { + let loopback = try configuration(baseURL: " http://127.0.0.1:11434/ ") + XCTAssertEqual(loopback.baseURL.absoluteString, "http://127.0.0.1:11434/v1") + XCTAssertEqual(loopback.apiURL(path: "models").absoluteString, "http://127.0.0.1:11434/v1/models") + XCTAssertEqual(loopback.hostScope, .loopback) + XCTAssertNil(loopback.privacyWarning) + + let lan = try configuration(baseURL: "http://192.168.1.50:8000/v1/") + XCTAssertEqual(lan.baseURL.absoluteString, "http://192.168.1.50:8000/v1") + XCTAssertEqual(lan.hostScope, .localNetwork) + XCTAssertNotNil(lan.privacyWarning) + + let mdns = try configuration(baseURL: "http://ollama.local:11434/v1") + XCTAssertEqual(mdns.hostScope, .localNetwork) + + let publicHTTPS = try configuration(baseURL: "https://models.example.com/custom/v1") + XCTAssertEqual(publicHTTPS.hostScope, .publicInternet) + XCTAssertNotNil(publicHTTPS.privacyWarning) + + let singleLabelHTTPS = try configuration(baseURL: "https://internal-llm:11434/v1") + XCTAssertEqual(singleLabelHTTPS.hostScope, .publicInternet) + } + + func test_configuration_rejectsInsecurePublicHTTPAndInvalidComponents() { + for insecure in [ + "http://models.example.com/v1", + "http://internal-llm:11434/v1", + "http://[2001:4860:4860::8888]/v1" + ] { + XCTAssertThrowsError(try configuration(baseURL: insecure), insecure) { error in + XCTAssertEqual(error as? OpenAICompatibleEndpointError, .insecurePublicHTTP) + } + } + for invalid in ["localhost:11434", "file:///tmp/model", "https://host/v1?token=secret"] { + XCTAssertThrowsError(try configuration(baseURL: invalid), invalid) + } + } + + func test_sseDecoder_handlesCompletionChatCommentsErrorsAndDone() throws { + XCTAssertEqual( + try OpenAICompatibleSSEDecoder.decode( + #"data: {"choices":[{"text":" hel"}]}"#, + mode: .completions + ), + .text(" hel") + ) + XCTAssertEqual( + try OpenAICompatibleSSEDecoder.decode( + #"data: {"choices":[{"delta":{"content":"lo"}}]}"#, + mode: .chatCompletions + ), + .text("lo") + ) + XCTAssertEqual(try OpenAICompatibleSSEDecoder.decode(": keep-alive", mode: .completions), .ignore) + XCTAssertEqual(try OpenAICompatibleSSEDecoder.decode("data: [DONE]", mode: .completions), .done) + XCTAssertEqual( + try OpenAICompatibleSSEDecoder.decode( + #"data: {"error":{"message":"model missing"}}"#, + mode: .completions + ), + .error("model missing") + ) + } + + func test_fetchModels_usesModelsRouteAndBearerAuthorization() async throws { + let client = makeClient() + EndpointStubURLProtocol.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "http://127.0.0.1:11434/v1/models") + XCTAssertEqual(request.timeoutInterval, 10) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer secret") + return Self.response( + request: request, + body: #"{"object":"list","data":[{"id":"zeta"},{"id":"alpha","owned_by":"library"}]}"# + ) + } + + let models = try await client.fetchModels( + configuration: configuration(), + apiKey: "secret" + ) + + XCTAssertEqual(models.map(\.id), ["alpha", "zeta"]) + XCTAssertEqual(models.first?.ownedBy, "library") + } + + func test_completionGeneration_postsStandardPayloadAndAccumulatesSSE() async throws { + let client = makeClient() + EndpointStubURLProtocol.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "http://127.0.0.1:11434/v1/completions") + XCTAssertEqual(request.timeoutInterval, 120) + XCTAssertNil(request.value(forHTTPHeaderField: "Authorization")) + let json = try Self.jsonBody(request) + XCTAssertEqual(json["model"] as? String, "gemma4:12b-mlx") + XCTAssertEqual(json["prompt"] as? String, "Complete this") + XCTAssertEqual(json["max_tokens"] as? Int, 12) + XCTAssertNil(json["messages"]) + return Self.response( + request: request, + contentType: "text/event-stream", + body: "data: {\"choices\":[{\"text\":\" hel\"}]}\n\n" + + "data: {\"choices\":[{\"text\":\"lo\"}]}\n\n" + + "data: [DONE]\n\n" + ) + } + var partials: [String] = [] + + let output = try await client.generate( + configuration: configuration(mode: .completions), + apiKey: nil, + prompt: "Complete this", + options: .init(maxPredictionTokens: 12, temperature: 0.2, topP: 0.8), + onPartialRawText: { partials.append($0) } + ) + + XCTAssertEqual(output, " hello") + XCTAssertEqual(partials, [" hel", " hello"]) + } + + func test_chatGeneration_postsSingleUserMessageAndReadsDeltaContent() async throws { + let client = makeClient() + EndpointStubURLProtocol.handler = { request in + XCTAssertEqual(request.url?.absoluteString, "http://127.0.0.1:11434/v1/chat/completions") + let json = try Self.jsonBody(request) + let messages = try XCTUnwrap(json["messages"] as? [[String: Any]]) + XCTAssertEqual(messages.count, 1) + XCTAssertEqual(messages.first?["role"] as? String, "user") + let content = try XCTUnwrap(messages.first?["content"] as? String) + XCTAssertTrue(content.hasPrefix("Continue the text at the end of the context.")) + XCTAssertTrue(content.hasSuffix("\n\nContinue me")) + XCTAssertEqual(json["reasoning_effort"] as? String, "none") + XCTAssertNil(json["prompt"]) + return Self.response( + request: request, + contentType: "text/event-stream", + body: "data: {\"choices\":[{\"delta\":{\"content\":\" next\"}}]}\n\n" + + "data: [DONE]\n\n" + ) + } + + let output = try await client.generate( + configuration: configuration(mode: .chatCompletions), + apiKey: nil, + prompt: "Continue me", + options: .init(maxPredictionTokens: 8, temperature: 0.1, topP: 0.7), + onPartialRawText: nil + ) + + XCTAssertEqual(output, " next") + } + + func test_generationMapsNonSuccessStatus() async { + let client = makeClient() + EndpointStubURLProtocol.handler = { request in + Self.response(request: request, statusCode: 401, body: #"{"error":{"message":"unauthorized"}}"#) + } + + do { + _ = try await client.generate( + configuration: configuration(), + apiKey: nil, + prompt: "text", + options: .init(maxPredictionTokens: 8, temperature: 0.1, topP: 0.7), + onPartialRawText: nil + ) + XCTFail("Expected the HTTP error") + } catch let error as OpenAICompatibleClientError { + XCTAssertEqual(error, .server(statusCode: 401)) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func test_generationCancellationStopsTheUnderlyingRequest() async { + let client = makeClient() + let started = expectation(description: "endpoint request started") + let stopped = expectation(description: "endpoint request stopped") + EndpointStubURLProtocol.holdRequests = true + EndpointStubURLProtocol.onStart = { started.fulfill() } + EndpointStubURLProtocol.onStop = { stopped.fulfill() } + + let generation = Task { @MainActor in + try await client.generate( + configuration: configuration(), + apiKey: nil, + prompt: "text", + options: .init(maxPredictionTokens: 8, temperature: 0.1, topP: 0.7), + onPartialRawText: nil + ) + } + + await fulfillment(of: [started], timeout: 1) + generation.cancel() + do { + _ = try await generation.value + XCTFail("Expected cancellation") + } catch is CancellationError { + // Foundation may surface cooperative cancellation directly. + } catch let error as URLError { + XCTAssertEqual(error.code, .cancelled) + } catch { + XCTFail("Unexpected error: \(error)") + } + await fulfillment(of: [stopped], timeout: 1) + } + + private func makeClient() -> OpenAICompatibleAPIClient { + let sessionConfiguration = URLSessionConfiguration.ephemeral + sessionConfiguration.protocolClasses = [EndpointStubURLProtocol.self] + return OpenAICompatibleAPIClient(session: URLSession(configuration: sessionConfiguration)) + } + + private func configuration( + baseURL: String = OpenAICompatibleEndpointConfiguration.defaultBaseURLString, + mode: OpenAICompatibleAPIMode = .chatCompletions + ) throws -> OpenAICompatibleEndpointConfiguration { + try OpenAICompatibleEndpointConfiguration( + baseURLString: baseURL, + modelName: "gemma4:12b-mlx", + apiMode: mode + ) + } + + private static func response( + request: URLRequest, + statusCode: Int = 200, + contentType: String = "application/json", + body: String + ) -> (HTTPURLResponse, Data) { + let response = HTTPURLResponse( + url: request.url!, + statusCode: statusCode, + httpVersion: nil, + headerFields: ["Content-Type": contentType] + )! + return (response, Data(body.utf8)) + } + + private static func jsonBody(_ request: URLRequest) throws -> [String: Any] { + let data = try XCTUnwrap(request.httpBody ?? Self.readBodyStream(request.httpBodyStream)) + return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + } + + private static func readBodyStream(_ stream: InputStream?) -> Data? { + guard let stream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: 4_096) + defer { buffer.deallocate() } + while stream.hasBytesAvailable { + let count = stream.read(buffer, maxLength: 4_096) + guard count > 0 else { break } + data.append(buffer, count: count) + } + return data + } +} + +private final class EndpointStubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + nonisolated(unsafe) static var holdRequests = false + nonisolated(unsafe) static var onStart: (() -> Void)? + nonisolated(unsafe) static var onStop: (() -> Void)? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.onStart?() + if Self.holdRequests { return } + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.unknown)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() { + Self.onStop?() + } +} diff --git a/CotabbyTests/SettingsAttentionEvaluatorTests.swift b/CotabbyTests/SettingsAttentionEvaluatorTests.swift index 14569e2e..a5e0cbff 100644 --- a/CotabbyTests/SettingsAttentionEvaluatorTests.swift +++ b/CotabbyTests/SettingsAttentionEvaluatorTests.swift @@ -10,14 +10,18 @@ final class SettingsAttentionEvaluatorTests: XCTestCase { selectedEngine: SuggestionEngineKind = .llamaOpenSource, foundationModelAvailable: Bool = true, foundationModelMessage: String = "Apple Intelligence is available.", - llamaRuntimeFailedReason: String? = nil + llamaRuntimeFailedReason: String? = nil, + endpointConfigurationError: String? = nil, + endpointConnectionFailedReason: String? = nil ) -> SettingsAttentionEvaluator.Inputs { SettingsAttentionEvaluator.Inputs( permissionsGranted: permissionsGranted, selectedEngine: selectedEngine, foundationModelAvailable: foundationModelAvailable, foundationModelMessage: foundationModelMessage, - llamaRuntimeFailedReason: llamaRuntimeFailedReason + llamaRuntimeFailedReason: llamaRuntimeFailedReason, + endpointConfigurationError: endpointConfigurationError, + endpointConnectionFailedReason: endpointConnectionFailedReason ) } @@ -67,6 +71,18 @@ final class SettingsAttentionEvaluatorTests: XCTestCase { XCTAssertEqual(categories, [.engineAndModel]) } + func test_endpointConfigurationFailure_flagsEngineAndModel() { + let inputs = makeInputs( + selectedEngine: .openAICompatible, + endpointConfigurationError: "Choose a model." + ) + XCTAssertEqual(SettingsAttentionEvaluator.categoriesNeedingAttention(inputs), [.engineAndModel]) + XCTAssertEqual( + SettingsAttentionEvaluator.calloutMessage(for: .engineAndModel, inputs: inputs), + "Choose a model." + ) + } + func test_callout_permissions_returnsActionableMessage() { let message = SettingsAttentionEvaluator.calloutMessage( for: .permissions, diff --git a/CotabbyTests/SuggestionEngineModelsTests.swift b/CotabbyTests/SuggestionEngineModelsTests.swift index 0d3f71b3..e5f5c8d0 100644 --- a/CotabbyTests/SuggestionEngineModelsTests.swift +++ b/CotabbyTests/SuggestionEngineModelsTests.swift @@ -8,6 +8,7 @@ final class SuggestionEngineModelsTests: XCTestCase { func test_suggestionEngineKind_displayLabelsArePinnedProductCopy() { XCTAssertEqual(SuggestionEngineKind.appleIntelligence.displayLabel, "Apple Intelligence") XCTAssertEqual(SuggestionEngineKind.llamaOpenSource.displayLabel, "Open Source") + XCTAssertEqual(SuggestionEngineKind.openAICompatible.displayLabel, "Local Endpoint") } func test_suggestionEngineKind_systemImageNamesArePinnedSharedGlyphs() { @@ -15,10 +16,11 @@ final class SuggestionEngineModelsTests: XCTestCase { // the engine looking like one object across every surface. XCTAssertEqual(SuggestionEngineKind.appleIntelligence.systemImageName, "apple.logo") XCTAssertEqual(SuggestionEngineKind.llamaOpenSource.systemImageName, "cpu.fill") + XCTAssertEqual(SuggestionEngineKind.openAICompatible.systemImageName, "network") } func test_suggestionEngineKind_idMatchesRawValueForEveryCase() { - XCTAssertEqual(SuggestionEngineKind.allCases.count, 2) + XCTAssertEqual(SuggestionEngineKind.allCases.count, 3) for kind in SuggestionEngineKind.allCases { XCTAssertEqual(kind.id, kind.rawValue) } @@ -28,11 +30,13 @@ final class SuggestionEngineModelsTests: XCTestCase { // Apple Intelligence has no GGUF files to manage; the OS owns its model. XCTAssertFalse(SuggestionEngineKind.appleIntelligence.supportsLocalModelManagement) XCTAssertTrue(SuggestionEngineKind.llamaOpenSource.supportsLocalModelManagement) + XCTAssertFalse(SuggestionEngineKind.openAICompatible.supportsLocalModelManagement) } func test_powerProfile_engineBridgesEachProfileToItsEngineKind() { XCTAssertEqual(PowerProfile.appleIntelligence.engine, .appleIntelligence) XCTAssertEqual(PowerProfile.llama(filename: "tabby.gguf").engine, .llamaOpenSource) + XCTAssertEqual(PowerProfile.openAICompatible(modelName: "gemma4").engine, .openAICompatible) } func test_disabledApplicationRule_identityIsBundleIdentifierAndSurvivesCodableRoundTrip() throws { diff --git a/CotabbyTests/SuggestionEngineRouterTests.swift b/CotabbyTests/SuggestionEngineRouterTests.swift index 156d8fa9..46e23c07 100644 --- a/CotabbyTests/SuggestionEngineRouterTests.swift +++ b/CotabbyTests/SuggestionEngineRouterTests.swift @@ -18,6 +18,7 @@ final class SuggestionEngineRouterRoutingTests: XCTestCase { let settings: SuggestionSettingsModel let foundation: ScriptedEngine let llama: ScriptedEngine + let endpoint: ScriptedEngine let metrics: PerformanceMetricsStore } @@ -62,16 +63,26 @@ final class SuggestionEngineRouterRoutingTests: XCTestCase { let metrics = PerformanceMetricsStore(userDefaults: defaults) let foundation = ScriptedEngine() let llama = ScriptedEngine() + let endpoint = ScriptedEngine() let router = SuggestionEngineRouter( suggestionSettings: settings, foundationModelEngine: foundation, llamaEngine: llama, performanceMetricsStore: metrics, qualityMetricsStore: SuggestionQualityMetricsStore(userDefaults: defaults), - llamaModelNameProvider: { llamaModelName } + llamaModelNameProvider: { llamaModelName }, + openAICompatibleEngine: endpoint, + endpointModelNameProvider: { "endpoint-model" } ) Self.retained.append(contentsOf: [router, settings, metrics] as [AnyObject]) - return Rig(router: router, settings: settings, foundation: foundation, llama: llama, metrics: metrics) + return Rig( + router: router, + settings: settings, + foundation: foundation, + llama: llama, + endpoint: endpoint, + metrics: metrics + ) } func test_appleIntelligenceSelection_routesToFoundationEngineAndRecordsMetric() async throws { @@ -104,6 +115,17 @@ final class SuggestionEngineRouterRoutingTests: XCTestCase { XCTAssertEqual(rig.metrics.entries.first?.modelName, "Llama") } + func test_endpointSelection_routesToEndpointAndRecordsModelName() async throws { + let rig = makeRig(engine: .openAICompatible) + + _ = try await rig.router.generateSuggestion(for: CotabbyTestFixtures.suggestionRequest()) + + XCTAssertEqual(rig.endpoint.requests.count, 1) + XCTAssertTrue(rig.foundation.requests.isEmpty) + XCTAssertTrue(rig.llama.requests.isEmpty) + XCTAssertEqual(rig.metrics.entries.first?.modelName, "endpoint-model") + } + func test_performanceTrackingOff_recordsNothing() async throws { let rig = makeRig(engine: .llamaOpenSource, performanceTracking: false) @@ -172,15 +194,20 @@ final class SuggestionEngineRouterRoutingTests: XCTestCase { await llamaRig.router.prewarm(for: CotabbyTestFixtures.suggestionRequest()) XCTAssertEqual(llamaRig.foundation.prewarmCount, 0) XCTAssertEqual(llamaRig.llama.prewarmCount, 1) + + let endpointRig = makeRig(engine: .openAICompatible) + await endpointRig.router.prewarm(for: CotabbyTestFixtures.suggestionRequest()) + XCTAssertEqual(endpointRig.endpoint.prewarmCount, 1) } - func test_resetCachedGenerationContext_fansOutToBothEngines() async { + func test_resetCachedGenerationContext_fansOutToAllEngines() async { let rig = makeRig(engine: .appleIntelligence) await rig.router.resetCachedGenerationContext() XCTAssertEqual(rig.foundation.resetCount, 1) XCTAssertEqual(rig.llama.resetCount, 1, "Switching engines must not leave stale state behind") + XCTAssertEqual(rig.endpoint.resetCount, 1) } func test_unavailableEngine_throwsItsConfiguredMessage() async { diff --git a/CotabbyTests/SuggestionSettingsModelTests.swift b/CotabbyTests/SuggestionSettingsModelTests.swift index 3a69adc0..cf7b1cc8 100644 --- a/CotabbyTests/SuggestionSettingsModelTests.swift +++ b/CotabbyTests/SuggestionSettingsModelTests.swift @@ -43,6 +43,9 @@ final class SuggestionSettingsModelTests: XCTestCase { model.setGloballyEnabled(false) model.pauseSuggestions(for: .indefinitely) model.selectEngine(.appleIntelligence) + model.setOpenAICompatibleBaseURL("https://models.example.com/v1") + model.setOpenAICompatibleModelName("endpoint-model") + model.setOpenAICompatibleAPIMode(.completions) model.selectWordCountPreset(.twelveToTwenty) model.setUsingCustomWordCountRange(true) model.setCustomWordCountRange(low: 3, high: 9) @@ -75,14 +78,19 @@ final class SuggestionSettingsModelTests: XCTestCase { model.setPowerBasedModelSwitchingEnabled(true) model.setBatteryEngine(.appleIntelligence) model.setBatteryModelFilename("small.gguf") + model.setBatteryEndpointModelName("small-endpoint") model.setPluggedInEngine(.llamaOpenSource) model.setPluggedInModelFilename("big.gguf") + model.setPluggedInEndpointModelName("big-endpoint") } let reloaded = makeModel() XCTAssertFalse(reloaded.isGloballyEnabled) XCTAssertTrue(reloaded.isTemporarilyPaused) XCTAssertEqual(reloaded.selectedEngine, .appleIntelligence) + XCTAssertEqual(reloaded.openAICompatibleBaseURL, "https://models.example.com/v1") + XCTAssertEqual(reloaded.openAICompatibleModelName, "endpoint-model") + XCTAssertEqual(reloaded.openAICompatibleAPIMode, .completions) XCTAssertEqual(reloaded.selectedWordCountPreset, .twelveToTwenty) XCTAssertTrue(reloaded.isUsingCustomWordCountRange) XCTAssertEqual(reloaded.customWordCountLowWords, 3) @@ -117,8 +125,10 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertTrue(reloaded.isPowerBasedModelSwitchingEnabled) XCTAssertEqual(reloaded.batteryEngine, .appleIntelligence) XCTAssertEqual(reloaded.batteryModelFilename, "small.gguf") + XCTAssertEqual(reloaded.batteryEndpointModelName, "small-endpoint") XCTAssertEqual(reloaded.pluggedInEngine, .llamaOpenSource) XCTAssertEqual(reloaded.pluggedInModelFilename, "big.gguf") + XCTAssertEqual(reloaded.pluggedInEndpointModelName, "big-endpoint") } // MARK: - Reset to defaults @@ -145,6 +155,9 @@ final class SuggestionSettingsModelTests: XCTestCase { // Dirty every field reachable through a setter with a genuine non-default value. model.setGloballyEnabled(false) model.selectEngine(.appleIntelligence) + model.setOpenAICompatibleBaseURL("https://models.example.com/v1") + model.setOpenAICompatibleModelName("endpoint-model") + model.setOpenAICompatibleAPIMode(.completions) model.selectWordCountPreset(.fourToSeven) model.setUsingCustomWordCountRange(true) model.setCustomWordCountRange(low: 3, high: 9) @@ -177,8 +190,10 @@ final class SuggestionSettingsModelTests: XCTestCase { model.setPowerBasedModelSwitchingEnabled(true) model.setBatteryEngine(.appleIntelligence) model.setBatteryModelFilename("small.gguf") + model.setBatteryEndpointModelName("small-endpoint") model.setPluggedInEngine(.appleIntelligence) model.setPluggedInModelFilename("big.gguf") + model.setPluggedInEndpointModelName("big-endpoint") model.resetToDefaults() @@ -192,6 +207,9 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(model.isGloballyEnabled, pristine.isGloballyEnabled) XCTAssertEqual(model.selectedEngine, pristine.selectedEngine) + XCTAssertEqual(model.openAICompatibleBaseURL, pristine.openAICompatibleBaseURL) + XCTAssertEqual(model.openAICompatibleModelName, pristine.openAICompatibleModelName) + XCTAssertEqual(model.openAICompatibleAPIMode, pristine.openAICompatibleAPIMode) XCTAssertEqual(model.selectedWordCountPreset, pristine.selectedWordCountPreset) XCTAssertEqual(model.isUsingCustomWordCountRange, pristine.isUsingCustomWordCountRange) XCTAssertEqual(model.customWordCountLowWords, pristine.customWordCountLowWords) @@ -225,8 +243,10 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(model.isPowerBasedModelSwitchingEnabled, pristine.isPowerBasedModelSwitchingEnabled) XCTAssertEqual(model.batteryEngine, pristine.batteryEngine) XCTAssertEqual(model.batteryModelFilename, pristine.batteryModelFilename) + XCTAssertEqual(model.batteryEndpointModelName, pristine.batteryEndpointModelName) XCTAssertEqual(model.pluggedInEngine, pristine.pluggedInEngine) XCTAssertEqual(model.pluggedInModelFilename, pristine.pluggedInModelFilename) + XCTAssertEqual(model.pluggedInEndpointModelName, pristine.pluggedInEndpointModelName) // Seeded (no-setter) fields: XCTAssertEqual(model.acceptanceKeyCode, pristine.acceptanceKeyCode) XCTAssertEqual(model.acceptanceKeyLabel, pristine.acceptanceKeyLabel) @@ -277,6 +297,27 @@ final class SuggestionSettingsModelTests: XCTestCase { XCTAssertEqual(model.pluggedInModelFilename, "large.gguf") } + func test_endpointPowerProfileAndCredentialReset() throws { + let credentials = InMemoryOpenAICompatibleCredentialStore(apiKey: "secret") + let model = SuggestionSettingsModel( + configuration: .standard, + userDefaults: defaults, + endpointCredentialStore: credentials + ) + + model.setBatteryProfile(.openAICompatible(modelName: "gemma4:12b-mlx")) + XCTAssertEqual(model.batteryProfile, .openAICompatible(modelName: "gemma4:12b-mlx")) + XCTAssertEqual(try model.openAICompatibleAPIKey(), "secret") + + try model.saveOpenAICompatibleAPIKey("replacement") + XCTAssertEqual(model.endpointCredentialRevision, 1) + + model.resetToDefaults() + + XCTAssertNil(try model.openAICompatibleAPIKey()) + XCTAssertEqual(model.endpointCredentialRevision, 2) + } + func test_initializePowerProfiles_seedsOnlyPristineProfiles() { let model = makeModel() diff --git a/CotabbyTests/SuggestionSettingsStoreTests.swift b/CotabbyTests/SuggestionSettingsStoreTests.swift index f7f05faa..483516af 100644 --- a/CotabbyTests/SuggestionSettingsStoreTests.swift +++ b/CotabbyTests/SuggestionSettingsStoreTests.swift @@ -357,12 +357,38 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.saveBatteryEngine(.appleIntelligence) store.savePluggedInEngine(.llamaOpenSource) store.savePluggedInModelFilename("big-model.gguf") + store.saveBatteryEndpointModelName("battery-endpoint") + store.savePluggedInEndpointModelName("plugged-endpoint") let data = store.load(configuration: .standard) XCTAssertEqual(data.batteryEngine, .appleIntelligence) XCTAssertEqual(data.pluggedInEngine, .llamaOpenSource) XCTAssertEqual(data.pluggedInModelFilename, "big-model.gguf") + XCTAssertEqual(data.batteryEndpointModelName, "battery-endpoint") + XCTAssertEqual(data.pluggedInEndpointModelName, "plugged-endpoint") + } + + func test_endpointDefaultsAndSettingsRoundTrip() async { + let defaults = makeIsolatedDefaults() + let store = SuggestionSettingsStore(userDefaults: defaults) + + let initial = store.load(configuration: .standard) + XCTAssertEqual( + initial.openAICompatibleBaseURL, + OpenAICompatibleEndpointConfiguration.defaultBaseURLString + ) + XCTAssertEqual(initial.openAICompatibleModelName, "") + XCTAssertEqual(initial.openAICompatibleAPIMode, .chatCompletions) + + store.saveOpenAICompatibleBaseURL("https://example.com/v1") + store.saveOpenAICompatibleModelName("custom") + store.saveOpenAICompatibleAPIMode(.completions) + let reloaded = store.load(configuration: .standard) + + XCTAssertEqual(reloaded.openAICompatibleBaseURL, "https://example.com/v1") + XCTAssertEqual(reloaded.openAICompatibleModelName, "custom") + XCTAssertEqual(reloaded.openAICompatibleAPIMode, .completions) } func test_load_legacyModelOnlyProfilePreservedWithOpenSourceEngine() async { @@ -529,6 +555,9 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.saveGhostTextOpacity(0.4) store.saveGhostTextSizeMultiplier(1.2) store.saveSelectedEngine(.appleIntelligence) + store.saveOpenAICompatibleBaseURL("https://example.com/v1") + store.saveOpenAICompatibleModelName("remote-model") + store.saveOpenAICompatibleAPIMode(.completions) store.saveSelectedWordCountPreset(.fourToSeven) store.saveUsingCustomWordCountRange(true) store.saveCustomWordCountRange(low: 3, high: 9) @@ -563,8 +592,10 @@ final class SuggestionSettingsStoreTests: XCTestCase { store.savePowerBasedModelSwitchingEnabled(true) store.saveBatteryEngine(.appleIntelligence) store.saveBatteryModelFilename("small.gguf") + store.saveBatteryEndpointModelName("small-endpoint") store.savePluggedInEngine(.appleIntelligence) store.savePluggedInModelFilename("big.gguf") + store.savePluggedInEndpointModelName("big-endpoint") defaults.set("Spanish", forKey: "cotabbyResponseLanguage") // Sanity: the dirty values really landed, so the assertions below aren't vacuous.