diff --git a/TypeWhisper.xcodeproj/project.pbxproj b/TypeWhisper.xcodeproj/project.pbxproj index 7f23f7417..6a7f211f0 100644 --- a/TypeWhisper.xcodeproj/project.pbxproj +++ b/TypeWhisper.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 204C804898EAE1127B62EC98 /* TestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E808D246301E36546EEB811F /* TestSupport.swift */; }; A1F000000000000000000102 /* PostUpdatePromptCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F000000000000000000101 /* PostUpdatePromptCoordinatorTests.swift */; }; A1C3E59B7D1042F9A8C6E2B1 /* PromptActionTemperaturePersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2D4F6A8C0E2143F9B7D5C1A /* PromptActionTemperaturePersistenceTests.swift */; }; + FEEDFACE00000000000000A1 /* PromptProcessingModelResolutionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEDFACE00000000000000B1 /* PromptProcessingModelResolutionTests.swift */; }; 24FFE19AC026C48AE32BCD7C /* AppFormatterServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5852D6767C5FA955B84C52 /* AppFormatterServiceTests.swift */; }; 24FFE19AC026C48AE32BCD7D /* SpeechPunctuationServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5852D6767C5FA955B84C53 /* SpeechPunctuationServiceTests.swift */; }; 24FFE19AC026C48AE32BCD7E /* DictationPunctuationProfileStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5852D6767C5FA955B84C54 /* DictationPunctuationProfileStoreTests.swift */; }; @@ -806,6 +807,7 @@ BDF548E65340A17A3A16590B /* PluginManifestValidationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginManifestValidationTests.swift; sourceTree = ""; }; BE6B6611B899F649B097A726 /* SnippetServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnippetServiceTests.swift; sourceTree = ""; }; B2D4F6A8C0E2143F9B7D5C1A /* PromptActionTemperaturePersistenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PromptActionTemperaturePersistenceTests.swift; sourceTree = ""; }; + FEEDFACE00000000000000B1 /* PromptProcessingModelResolutionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PromptProcessingModelResolutionTests.swift; sourceTree = ""; }; CE5852D6767C5FA955B84C52 /* AppFormatterServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppFormatterServiceTests.swift; sourceTree = ""; }; CE5852D6767C5FA955B84C53 /* SpeechPunctuationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechPunctuationServiceTests.swift; sourceTree = ""; }; 50756E40757C6BC13C52ED74 /* NumberWordNormalizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NumberWordNormalizerTests.swift; sourceTree = ""; }; @@ -2140,6 +2142,7 @@ 91B4D7E2C5A809F1632E4B7D /* PluginRegistryServiceTests.swift */, 545000000000000000000001 /* SetupWizardRecommendationAvailabilityTests.swift */, B2D4F6A8C0E2143F9B7D5C1A /* PromptActionTemperaturePersistenceTests.swift */, + FEEDFACE00000000000000B1 /* PromptProcessingModelResolutionTests.swift */, 2A7B3C4D5E6F708192A3B4C7 /* PromptActionsViewModelWizardTests.swift */, 2A7B3C4D5E6F708192A3B4C5 /* PromptWizardComposerTests.swift */, 2A7B3C4D5E6F708192A3B4C6 /* PromptWizardInferenceServiceTests.swift */, @@ -3542,6 +3545,7 @@ 91B4D7E2C5A809F1632E4B7C /* PluginRegistryServiceTests.swift in Sources */, 545000000000000000000002 /* SetupWizardRecommendationAvailabilityTests.swift in Sources */, A1C3E59B7D1042F9A8C6E2B1 /* PromptActionTemperaturePersistenceTests.swift in Sources */, + FEEDFACE00000000000000A1 /* PromptProcessingModelResolutionTests.swift in Sources */, 1F7A2C3D4E5B60718293A4C7 /* PromptActionsViewModelWizardTests.swift in Sources */, 1F7A2C3D4E5B60718293A4C5 /* PromptWizardComposerTests.swift in Sources */, 1F7A2C3D4E5B60718293A4C6 /* PromptWizardInferenceServiceTests.swift in Sources */, diff --git a/TypeWhisper/Services/PromptProcessingService.swift b/TypeWhisper/Services/PromptProcessingService.swift index e8966a8ac..49c09da08 100644 --- a/TypeWhisper/Services/PromptProcessingService.swift +++ b/TypeWhisper/Services/PromptProcessingService.swift @@ -362,38 +362,89 @@ class PromptProcessingService: ObservableObject { ) } + /// The provider plugin's recommended fallback for when no explicit model + /// selection exists. A display/processing hint, never a user preference. + func defaultModelId(for providerId: String) -> String? { + (PluginManager.shared.llmProvider(for: providerId) as? LLMModelSelectable)?.defaultModelId as? String + } + private func resolvedModelId( for providerId: String, requestedModel: String?, persistGlobalSelection: Bool ) -> String? { - let models = modelsForProvider(providerId) - guard !models.isEmpty else { return requestedModel } + let preferredModelId = (PluginManager.shared.llmProvider(for: providerId) as? LLMModelSelectable)?.preferredModelId as? String + let resolution = Self.resolveModel( + requestedModel: requestedModel, + preferredModelId: preferredModelId, + selectedCloudModel: selectedCloudModel, + availableModelIds: modelsForProvider(providerId).map(\.id), + providerDefaultModelId: defaultModelId(for: providerId) + ) - let validIds = Set(models.map(\.id)) - if let requestedModel, - validIds.contains(requestedModel) { - return requestedModel + if persistGlobalSelection, + resolution.persistGlobally, + let modelId = resolution.modelId, + selectedCloudModel != modelId { + selectedCloudModel = modelId } - let preferredModelId = (PluginManager.shared.llmProvider(for: providerId) as? LLMModelSelectable)?.preferredModelId as? String - let fallbackModelId: String? - if let preferredModelId, - validIds.contains(preferredModelId) { - fallbackModelId = preferredModelId - } else if !selectedCloudModel.isEmpty, - validIds.contains(selectedCloudModel) { - fallbackModelId = selectedCloudModel - } else { - fallbackModelId = models.first?.id + return resolution.modelId + } + + struct ModelResolution: Equatable { + let modelId: String? + /// Whether `modelId` reflects a deliberate choice (a still-valid global + /// selection's provider preference) that may be written back to the + /// legacy `llmCloudModel` key, versus a pure fallback guess. + let persistGlobally: Bool + } + + /// Resolves the model to use for a provider, kept pure so the persistence + /// decision is unit-testable. + /// + /// The global is written through with a deliberate choice — the provider + /// plugin's preference, or a self-healing repair of an existing-but-invalid + /// global — but never when no model was ever selected. Adopting the + /// alphabetically-first (oldest) model as a permanent default the user never + /// chose is how a later-retired model (e.g. `gemini-2.0-flash`, which sorts + /// first) silently poisons the global key and makes every future run 404. + static func resolveModel( + requestedModel: String?, + preferredModelId: String?, + selectedCloudModel: String, + availableModelIds: [String], + providerDefaultModelId: String? = nil + ) -> ModelResolution { + guard !availableModelIds.isEmpty else { + return ModelResolution(modelId: requestedModel, persistGlobally: false) } - if persistGlobalSelection, - let fallbackModelId, - selectedCloudModel != fallbackModelId { - selectedCloudModel = fallbackModelId + let validIds = Set(availableModelIds) + if let requestedModel, validIds.contains(requestedModel) { + return ModelResolution(modelId: requestedModel, persistGlobally: false) + } + + if let preferredModelId, validIds.contains(preferredModelId) { + return ModelResolution(modelId: preferredModelId, persistGlobally: true) + } + + if !selectedCloudModel.isEmpty, validIds.contains(selectedCloudModel) { + return ModelResolution(modelId: selectedCloudModel, persistGlobally: false) } - return fallbackModelId + // Fall back for this run, preferring the provider's recommended default + // over the first available model — for providers whose model list sorts + // a retired model first (Gemini's `gemini-2.0-flash`), first-available + // would 404. Persist the fallback only to repair a non-empty global + // that is no longer valid (self-healing); when no model was ever + // selected, use it transiently without poisoning the global. + let fallbackModelId = providerDefaultModelId.flatMap { validIds.contains($0) ? $0 : nil } + ?? availableModelIds.first + let isRepairingInvalidSelection = !selectedCloudModel.isEmpty + return ModelResolution( + modelId: fallbackModelId, + persistGlobally: isRepairingInvalidSelection + ) } } diff --git a/TypeWhisper/Views/PromptActionsSettingsView.swift b/TypeWhisper/Views/PromptActionsSettingsView.swift index 0dd67cebe..8e90b8b6c 100644 --- a/TypeWhisper/Views/PromptActionsSettingsView.swift +++ b/TypeWhisper/Views/PromptActionsSettingsView.swift @@ -171,7 +171,8 @@ struct PromptActionsSettingsView: View { ModelPickerView( models: processingService.modelsForProvider(processingService.selectedProviderId), - selection: $processingService.selectedCloudModel + selection: $processingService.selectedCloudModel, + fallbackModelId: processingService.defaultModelId(for: processingService.selectedProviderId) ) .frame(maxWidth: 320, alignment: .leading) @@ -377,6 +378,9 @@ private func promptProviderFixedModelName(for providerId: String) -> String? { struct ModelPickerView: View { let models: [PluginModelInfo] @Binding var selection: String + /// Provider-recommended model to preselect when the current selection is + /// empty or invalid; falls back to the first listed model when absent. + var fallbackModelId: String? = nil @State private var searchText = "" private var filteredModels: [PluginModelInfo] { @@ -395,6 +399,12 @@ struct ModelPickerView: View { .controlSize(.small) } Picker(String(localized: "Model"), selection: $selection) { + if let defaultModel = models.first(where: { $0.id == effectiveFallbackId }) { + Text(localizedAppText( + "Default (\(defaultModel.displayName))", + de: "Standard (\(defaultModel.displayName))" + )).tag("") + } ForEach(filteredModels, id: \.id) { model in Text(model.displayName).tag(model.id) } @@ -408,10 +418,22 @@ struct ModelPickerView: View { } } + /// The model runtime resolution will use when no explicit selection + /// exists: the provider default when listed, otherwise the first model. + private var effectiveFallbackId: String? { + fallbackModelId.flatMap { id in + models.contains(where: { $0.id == id }) ? id : nil + } ?? models.first?.id + } + private func ensureValidSelection() { - if selection.isEmpty || !models.contains(where: { $0.id == selection }) { - selection = models.first?.id ?? "" - } + // An empty selection is the deliberate "use the provider default" + // state (shown as the Default row) — leave it untouched so a + // transient hint is never auto-promoted into a stored user choice. + // Only self-heal a non-empty selection that is no longer listed. + guard !selection.isEmpty, + !models.contains(where: { $0.id == selection }) else { return } + selection = effectiveFallbackId ?? "" } } diff --git a/TypeWhisperPluginSDK/Package.swift b/TypeWhisperPluginSDK/Package.swift index 37fdca5e7..1f4eee911 100644 --- a/TypeWhisperPluginSDK/Package.swift +++ b/TypeWhisperPluginSDK/Package.swift @@ -62,6 +62,46 @@ let package = Package( .process("manifest.json"), ] ), + .target( + name: "GeminiPlugin", + dependencies: ["TypeWhisperPluginSDK"], + path: "Plugins/GeminiPlugin", + exclude: ["Tests"], + resources: [ + .process("Localizable.xcstrings"), + .process("manifest.json"), + ] + ), + .target( + name: "CerebrasPlugin", + dependencies: ["TypeWhisperPluginSDK"], + path: "Plugins/CerebrasPlugin", + exclude: ["Tests"], + resources: [ + .process("Localizable.xcstrings"), + .process("manifest.json"), + ] + ), + .target( + name: "FireworksPlugin", + dependencies: ["TypeWhisperPluginSDK"], + path: "Plugins/FireworksPlugin", + exclude: ["Tests"], + resources: [ + .process("Localizable.xcstrings"), + .process("manifest.json"), + ] + ), + .target( + name: "ClaudePlugin", + dependencies: ["TypeWhisperPluginSDK"], + path: "Plugins/ClaudePlugin", + exclude: ["Tests"], + resources: [ + .process("Localizable.xcstrings"), + .process("manifest.json"), + ] + ), .target( name: "Qwen3Plugin", dependencies: [ @@ -250,6 +290,42 @@ let package = Package( ], path: "Plugins/GroqPlugin/Tests" ), + .testTarget( + name: "GeminiPluginTests", + dependencies: [ + "TypeWhisperPluginSDK", + "TypeWhisperPluginSDKTesting", + "GeminiPlugin", + ], + path: "Plugins/GeminiPlugin/Tests" + ), + .testTarget( + name: "CerebrasPluginTests", + dependencies: [ + "TypeWhisperPluginSDK", + "TypeWhisperPluginSDKTesting", + "CerebrasPlugin", + ], + path: "Plugins/CerebrasPlugin/Tests" + ), + .testTarget( + name: "FireworksPluginTests", + dependencies: [ + "TypeWhisperPluginSDK", + "TypeWhisperPluginSDKTesting", + "FireworksPlugin", + ], + path: "Plugins/FireworksPlugin/Tests" + ), + .testTarget( + name: "ClaudePluginTests", + dependencies: [ + "TypeWhisperPluginSDK", + "TypeWhisperPluginSDKTesting", + "ClaudePlugin", + ], + path: "Plugins/ClaudePlugin/Tests" + ), .testTarget( name: "Qwen3PluginTests", dependencies: [ diff --git a/TypeWhisperPluginSDK/Plugins/CerebrasPlugin/CerebrasPlugin.swift b/TypeWhisperPluginSDK/Plugins/CerebrasPlugin/CerebrasPlugin.swift index 246735f7b..f0de857fa 100644 --- a/TypeWhisperPluginSDK/Plugins/CerebrasPlugin/CerebrasPlugin.swift +++ b/TypeWhisperPluginSDK/Plugins/CerebrasPlugin/CerebrasPlugin.swift @@ -5,7 +5,7 @@ import TypeWhisperPluginSDK // MARK: - Plugin Entry Point @objc(CerebrasPlugin) -final class CerebrasPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { +final class CerebrasPlugin: NSObject, LLMProviderPlugin, LLMModelSelectable, @unchecked Sendable { static let pluginId = "com.typewhisper.cerebras" static let pluginName = "Cerebras" @@ -32,7 +32,6 @@ final class CerebrasPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { _fetchedModels = models } _selectedLLMModelId = host.userDefault(forKey: "selectedLLMModel") as? String - ?? supportedModels.first?.id _llmTemperatureModeRaw = host.userDefault(forKey: "llmTemperatureMode") as? String ?? PluginLLMTemperatureMode.providerDefault.rawValue _llmTemperatureValue = host.userDefault(forKey: "llmTemperatureValue") as? Double @@ -108,6 +107,7 @@ final class CerebrasPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { } var selectedLLMModelId: String? { _selectedLLMModelId } + @objc var preferredModelId: String? { _selectedLLMModelId } var llmTemperatureMode: PluginLLMTemperatureMode { PluginLLMTemperatureMode(rawValue: _llmTemperatureModeRaw) ?? .providerDefault } diff --git a/TypeWhisperPluginSDK/Plugins/CerebrasPlugin/Tests/CerebrasPluginTests.swift b/TypeWhisperPluginSDK/Plugins/CerebrasPlugin/Tests/CerebrasPluginTests.swift new file mode 100644 index 000000000..7e1169ba1 --- /dev/null +++ b/TypeWhisperPluginSDK/Plugins/CerebrasPlugin/Tests/CerebrasPluginTests.swift @@ -0,0 +1,24 @@ +import Foundation +import XCTest +import TypeWhisperPluginSDK +@_spi(Testing) import TypeWhisperPluginSDKTesting +@testable import CerebrasPlugin + +final class CerebrasPluginTests: XCTestCase { + func testPreferredModelIdReflectsSelectedLLMModel() throws { + let host = try PluginTestHostServices() + let plugin = CerebrasPlugin() + plugin.activate(host: host) + + XCTAssertNil( + (plugin as? LLMModelSelectable)?.preferredModelId ?? nil, + "preferredModelId must be nil until the user selects a model" + ) + + let target = try XCTUnwrap(plugin.supportedModels.first?.id) + plugin.selectLLMModel(target) + + let preferred = (plugin as? LLMModelSelectable)?.preferredModelId + XCTAssertEqual(preferred, target) + } +} diff --git a/TypeWhisperPluginSDK/Plugins/ClaudePlugin/ClaudePlugin.swift b/TypeWhisperPluginSDK/Plugins/ClaudePlugin/ClaudePlugin.swift index 864e20160..dffbdf086 100644 --- a/TypeWhisperPluginSDK/Plugins/ClaudePlugin/ClaudePlugin.swift +++ b/TypeWhisperPluginSDK/Plugins/ClaudePlugin/ClaudePlugin.swift @@ -5,7 +5,7 @@ import TypeWhisperPluginSDK // MARK: - Plugin Entry Point @objc(ClaudePlugin) -final class ClaudePlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { +final class ClaudePlugin: NSObject, LLMProviderPlugin, LLMModelSelectable, @unchecked Sendable { static let pluginId = "com.typewhisper.claude" static let pluginName = "Claude" @@ -23,7 +23,6 @@ final class ClaudePlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { self.host = host _apiKey = host.loadSecret(key: "api-key") _selectedLLMModelId = host.userDefault(forKey: "selectedLLMModel") as? String - ?? supportedModels.first?.id _llmTemperatureModeRaw = host.userDefault(forKey: "llmTemperatureMode") as? String ?? PluginLLMTemperatureMode.providerDefault.rawValue _llmTemperatureValue = host.userDefault(forKey: "llmTemperatureValue") as? Double @@ -86,6 +85,7 @@ final class ClaudePlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { } var selectedLLMModelId: String? { _selectedLLMModelId } + @objc var preferredModelId: String? { _selectedLLMModelId } var llmTemperatureMode: PluginLLMTemperatureMode { PluginLLMTemperatureMode(rawValue: _llmTemperatureModeRaw) ?? .providerDefault } diff --git a/TypeWhisperPluginSDK/Plugins/ClaudePlugin/Tests/ClaudePluginTests.swift b/TypeWhisperPluginSDK/Plugins/ClaudePlugin/Tests/ClaudePluginTests.swift new file mode 100644 index 000000000..c26c64c88 --- /dev/null +++ b/TypeWhisperPluginSDK/Plugins/ClaudePlugin/Tests/ClaudePluginTests.swift @@ -0,0 +1,24 @@ +import Foundation +import XCTest +import TypeWhisperPluginSDK +@_spi(Testing) import TypeWhisperPluginSDKTesting +@testable import ClaudePlugin + +final class ClaudePluginTests: XCTestCase { + func testPreferredModelIdReflectsSelectedLLMModel() throws { + let host = try PluginTestHostServices() + let plugin = ClaudePlugin() + plugin.activate(host: host) + + XCTAssertNil( + (plugin as? LLMModelSelectable)?.preferredModelId ?? nil, + "preferredModelId must be nil until the user selects a model" + ) + + let target = try XCTUnwrap(plugin.supportedModels.first?.id) + plugin.selectLLMModel(target) + + let preferred = (plugin as? LLMModelSelectable)?.preferredModelId + XCTAssertEqual(preferred, target) + } +} diff --git a/TypeWhisperPluginSDK/Plugins/FireworksPlugin/FireworksPlugin.swift b/TypeWhisperPluginSDK/Plugins/FireworksPlugin/FireworksPlugin.swift index a2ca53967..c0816b7b6 100644 --- a/TypeWhisperPluginSDK/Plugins/FireworksPlugin/FireworksPlugin.swift +++ b/TypeWhisperPluginSDK/Plugins/FireworksPlugin/FireworksPlugin.swift @@ -5,7 +5,7 @@ import TypeWhisperPluginSDK // MARK: - Plugin Entry Point @objc(FireworksPlugin) -final class FireworksPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTermsCapabilityProviding, LLMProviderPlugin, @unchecked Sendable { +final class FireworksPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTermsCapabilityProviding, LLMProviderPlugin, LLMModelSelectable, @unchecked Sendable { static let pluginId = "com.typewhisper.fireworks" static let pluginName = "Fireworks AI" @@ -35,7 +35,6 @@ final class FireworksPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTerm _selectedModelId = host.userDefault(forKey: "selectedModel") as? String ?? transcriptionModels.first?.id _selectedLLMModelId = host.userDefault(forKey: "selectedLLMModel") as? String - ?? supportedModels.first?.id _llmTemperatureModeRaw = host.userDefault(forKey: "llmTemperatureMode") as? String ?? PluginLLMTemperatureMode.providerDefault.rawValue _llmTemperatureValue = host.userDefault(forKey: "llmTemperatureValue") as? Double @@ -212,6 +211,7 @@ final class FireworksPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTerm } var selectedLLMModelId: String? { _selectedLLMModelId } + @objc var preferredModelId: String? { _selectedLLMModelId } var llmTemperatureMode: PluginLLMTemperatureMode { PluginLLMTemperatureMode(rawValue: _llmTemperatureModeRaw) ?? .providerDefault } diff --git a/TypeWhisperPluginSDK/Plugins/FireworksPlugin/Tests/FireworksPluginTests.swift b/TypeWhisperPluginSDK/Plugins/FireworksPlugin/Tests/FireworksPluginTests.swift new file mode 100644 index 000000000..5c07eff88 --- /dev/null +++ b/TypeWhisperPluginSDK/Plugins/FireworksPlugin/Tests/FireworksPluginTests.swift @@ -0,0 +1,24 @@ +import Foundation +import XCTest +import TypeWhisperPluginSDK +@_spi(Testing) import TypeWhisperPluginSDKTesting +@testable import FireworksPlugin + +final class FireworksPluginTests: XCTestCase { + func testPreferredModelIdReflectsSelectedLLMModel() throws { + let host = try PluginTestHostServices() + let plugin = FireworksPlugin() + plugin.activate(host: host) + + XCTAssertNil( + (plugin as? LLMModelSelectable)?.preferredModelId ?? nil, + "preferredModelId must be nil until the user selects a model" + ) + + let target = try XCTUnwrap(plugin.supportedModels.first?.id) + plugin.selectLLMModel(target) + + let preferred = (plugin as? LLMModelSelectable)?.preferredModelId + XCTAssertEqual(preferred, target) + } +} diff --git a/TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift b/TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift index df77f97f9..d53de48e7 100644 --- a/TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift +++ b/TypeWhisperPluginSDK/Plugins/GeminiPlugin/GeminiPlugin.swift @@ -5,7 +5,7 @@ import TypeWhisperPluginSDK // MARK: - Plugin Entry Point @objc(GeminiPlugin) -final class GeminiPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { +final class GeminiPlugin: NSObject, LLMProviderPlugin, LLMModelSelectable, @unchecked Sendable { static let pluginId = "com.typewhisper.gemini" static let pluginName = "Gemini" private static let cachedLLMModelsKey = "fetchedLLMModels.v2" @@ -48,7 +48,6 @@ final class GeminiPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { _fetchedLLMModels = models } host.setUserDefault(nil, forKey: Self.legacyCachedLLMModelsKey) - _selectedLLMModelId = host.userDefault(forKey: Self.selectedLLMModelKey) as? String _llmTemperatureModeRaw = host.userDefault(forKey: "llmTemperatureMode") as? String ?? PluginLLMTemperatureMode.providerDefault.rawValue _llmTemperatureValue = host.userDefault(forKey: "llmTemperatureValue") as? Double @@ -76,6 +75,17 @@ final class GeminiPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { PluginModelInfo(id: "gemini-flash-lite-latest", displayName: "Gemini Flash-Lite Latest"), ] + /// Transient default when the user has not selected a model. Prefers the + /// curated auto-updating alias over `supportedModels.first`, which for + /// fetched models is the alphabetically-oldest (and possibly retired) + /// model, e.g. `gemini-2.0-flash`. + private static let curatedDefaultModelId = "gemini-flash-latest" + + fileprivate var defaultLLMModelId: String? { + let models = supportedModels + return models.first(where: { $0.id == Self.curatedDefaultModelId })?.id ?? models.first?.id + } + var supportedModels: [PluginModelInfo] { if !_fetchedLLMModels.isEmpty { return _fetchedLLMModels.map { PluginModelInfo(id: $0.id, displayName: $0.displayName ?? $0.id) } @@ -101,7 +111,9 @@ final class GeminiPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { guard let apiKey = _apiKey, !apiKey.isEmpty else { throw PluginChatError.notConfigured } - let modelId = model ?? _selectedLLMModelId ?? supportedModels.first!.id + guard let modelId = model ?? _selectedLLMModelId ?? defaultLLMModelId else { + throw PluginChatError.notConfigured + } return try await chatHelper.process( apiKey: apiKey, model: modelId, @@ -117,6 +129,8 @@ final class GeminiPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { } var selectedLLMModelId: String? { _selectedLLMModelId } + @objc var preferredModelId: String? { _selectedLLMModelId } + @objc var defaultModelId: String? { defaultLLMModelId } var llmTemperatureMode: PluginLLMTemperatureMode { PluginLLMTemperatureMode(rawValue: _llmTemperatureModeRaw) ?? .providerDefault } @@ -236,22 +250,19 @@ final class GeminiPlugin: NSObject, LLMProviderPlugin, @unchecked Sendable { return !excludedCompatibleModelTokens.contains { id.contains($0) } } + /// Validates the persisted selection against the current model list. + /// `_selectedLLMModelId` (and thus `preferredModelId`) only ever holds an + /// explicit, still-valid user selection — a fallback is never seeded into + /// it or persisted, so the host cannot mistake the alphabetically-oldest + /// fetched model for a deliberate choice. The stored value is kept even + /// while invalid so it re-validates if the model reappears after a fetch. private func normalizeSelectedModel() { + let storedModelId = host?.userDefault(forKey: Self.selectedLLMModelKey) as? String let supportedIds = Set(supportedModels.map(\.id)) - guard !supportedIds.isEmpty else { + if let storedModelId, supportedIds.contains(storedModelId) { + _selectedLLMModelId = storedModelId + } else { _selectedLLMModelId = nil - return - } - - if let selectedModelId = _selectedLLMModelId, - supportedIds.contains(selectedModelId) { - return - } - - let fallbackModelId = supportedModels.first?.id - _selectedLLMModelId = fallbackModelId - if let fallbackModelId { - host?.setUserDefault(fallbackModelId, forKey: Self.selectedLLMModelKey) } } @@ -449,7 +460,7 @@ private struct GeminiSettingsView: View { if let key = plugin._apiKey, !key.isEmpty { apiKeyInput = key } - selectedModel = plugin.selectedLLMModelId ?? plugin.supportedModels.first?.id ?? "" + selectedModel = plugin.selectedLLMModelId ?? plugin.defaultLLMModelId ?? "" llmTemperatureMode = plugin.llmTemperatureMode llmTemperatureValue = plugin.llmTemperatureValue fetchedLLMModels = plugin._fetchedLLMModels @@ -496,9 +507,9 @@ private struct GeminiSettingsView: View { fetchedLLMModels = models plugin.setFetchedLLMModels(models) if !models.contains(where: { $0.id == selectedModel }), - let first = models.first { - selectedModel = first.id - plugin.selectLLMModel(first.id) + let fallback = plugin.defaultLLMModelId { + selectedModel = fallback + plugin.selectLLMModel(fallback) } } } diff --git a/TypeWhisperPluginSDK/Plugins/GeminiPlugin/Tests/GeminiPluginTests.swift b/TypeWhisperPluginSDK/Plugins/GeminiPlugin/Tests/GeminiPluginTests.swift new file mode 100644 index 000000000..e21babfdd --- /dev/null +++ b/TypeWhisperPluginSDK/Plugins/GeminiPlugin/Tests/GeminiPluginTests.swift @@ -0,0 +1,104 @@ +import Foundation +import XCTest +import TypeWhisperPluginSDK +@_spi(Testing) import TypeWhisperPluginSDKTesting +@testable import GeminiPlugin + +final class GeminiPluginTests: XCTestCase { + private static let cachedLLMModelsKey = "fetchedLLMModels.v2" + private static let selectedLLMModelKey = "selectedLLMModel" + + private static func cachedModelsData() throws -> Data { + try JSONEncoder().encode([ + GeminiFetchedModel(id: "gemini-2.0-flash", displayName: "Gemini 2.0 Flash"), + GeminiFetchedModel(id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash"), + GeminiFetchedModel(id: "gemini-flash-latest", displayName: "Gemini Flash Latest"), + ]) + } + + func testPreferredModelIdReflectsSelectedLLMModel() throws { + let host = try PluginTestHostServices() + let plugin = GeminiPlugin() + plugin.activate(host: host) + + XCTAssertNil( + (plugin as? LLMModelSelectable)?.preferredModelId ?? nil, + "preferredModelId must be nil until the user selects a model" + ) + + let target = try XCTUnwrap(plugin.supportedModels.first?.id) + plugin.selectLLMModel(target) + + let preferred = (plugin as? LLMModelSelectable)?.preferredModelId + XCTAssertEqual(preferred, target) + } + + func testFreshActivationDoesNotExposeOrPersistOldestFetchedModel() throws { + let host = try PluginTestHostServices( + defaults: [Self.cachedLLMModelsKey: try Self.cachedModelsData()] + ) + let plugin = GeminiPlugin() + plugin.activate(host: host) + + XCTAssertEqual(plugin.supportedModels.first?.id, "gemini-2.0-flash") + XCTAssertNil( + (plugin as? LLMModelSelectable)?.preferredModelId ?? nil, + "fresh activation must not expose the alphabetically-oldest fetched model as a preference" + ) + XCTAssertNil( + host.userDefault(forKey: Self.selectedLLMModelKey), + "fresh activation must not persist a model the user never selected" + ) + } + + func testInvalidStoredSelectionIsNotReplacedByOldestFetchedModel() throws { + let host = try PluginTestHostServices( + defaults: [ + Self.cachedLLMModelsKey: try Self.cachedModelsData(), + Self.selectedLLMModelKey: "gemini-removed-model", + ] + ) + let plugin = GeminiPlugin() + plugin.activate(host: host) + + XCTAssertNil( + (plugin as? LLMModelSelectable)?.preferredModelId ?? nil, + "a stale selection must not be normalized into a fallback preference" + ) + XCTAssertEqual( + host.userDefault(forKey: Self.selectedLLMModelKey) as? String, + "gemini-removed-model", + "the stored selection is kept so it can re-validate if the model reappears" + ) + } + + func testDefaultModelIdPrefersCuratedAliasOverOldestFetchedModel() throws { + let host = try PluginTestHostServices( + defaults: [Self.cachedLLMModelsKey: try Self.cachedModelsData()] + ) + let plugin = GeminiPlugin() + plugin.activate(host: host) + + XCTAssertEqual( + (plugin as? LLMModelSelectable)?.defaultModelId, + "gemini-flash-latest", + "the host-visible default must be the curated alias, not the retired alphabetically-first model" + ) + } + + func testValidStoredSelectionSurvivesActivation() throws { + let host = try PluginTestHostServices( + defaults: [ + Self.cachedLLMModelsKey: try Self.cachedModelsData(), + Self.selectedLLMModelKey: "gemini-2.5-flash", + ] + ) + let plugin = GeminiPlugin() + plugin.activate(host: host) + + XCTAssertEqual( + (plugin as? LLMModelSelectable)?.preferredModelId, + "gemini-2.5-flash" + ) + } +} diff --git a/TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift b/TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift index 68ea30ce5..2929f2642 100644 --- a/TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift +++ b/TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift @@ -5,7 +5,7 @@ import TypeWhisperPluginSDK // MARK: - Plugin Entry Point @objc(GroqPlugin) -final class GroqPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTermsCapabilityProviding, LLMProviderPlugin, @unchecked Sendable { +final class GroqPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTermsCapabilityProviding, LLMProviderPlugin, LLMModelSelectable, @unchecked Sendable { static let pluginId = "com.typewhisper.groq" static let pluginName = "Groq" private static let transcriptionRequestTimeout: TimeInterval = 600 @@ -41,7 +41,6 @@ final class GroqPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTermsCapa _selectedModelId = host.userDefault(forKey: "selectedModel") as? String ?? transcriptionModels.first?.id _selectedLLMModelId = host.userDefault(forKey: "selectedLLMModel") as? String - ?? supportedModels.first?.id _llmTemperatureModeRaw = host.userDefault(forKey: "llmTemperatureMode") as? String ?? PluginLLMTemperatureMode.providerDefault.rawValue _llmTemperatureValue = host.userDefault(forKey: "llmTemperatureValue") as? Double @@ -169,6 +168,7 @@ final class GroqPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTermsCapa } var selectedLLMModelId: String? { _selectedLLMModelId } + @objc var preferredModelId: String? { _selectedLLMModelId } var llmTemperatureMode: PluginLLMTemperatureMode { PluginLLMTemperatureMode(rawValue: _llmTemperatureModeRaw) ?? .providerDefault } diff --git a/TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift b/TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift index e4df59fb0..a49bb41b9 100644 --- a/TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift +++ b/TypeWhisperPluginSDK/Plugins/GroqPlugin/Tests/GroqPluginTests.swift @@ -46,6 +46,23 @@ final class GroqPluginTests: XCTestCase { XCTAssertFalse(bodyText.contains(#"filename="audio.wav""#)) } + func testPreferredModelIdReflectsSelectedLLMModel() throws { + let host = try PluginTestHostServices() + let plugin = GroqPlugin() + plugin.activate(host: host) + + XCTAssertNil( + (plugin as? LLMModelSelectable)?.preferredModelId ?? nil, + "preferredModelId must be nil until the user selects a model" + ) + + let target = try XCTUnwrap(plugin.supportedModels.first?.id) + plugin.selectLLMModel(target) + + let preferred = (plugin as? LLMModelSelectable)?.preferredModelId + XCTAssertEqual(preferred, target) + } + private static func httpResponse(url: String, statusCode: Int) -> HTTPURLResponse { HTTPURLResponse( url: URL(string: url)!, diff --git a/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift b/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift index a7c5cafa7..4c738b3d6 100644 --- a/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift +++ b/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/HostServices.swift @@ -726,13 +726,7 @@ public struct PluginOpenAIChatHelper: Sendable { case 429: throw PluginChatError.rateLimited default: - var displayMessage = "HTTP \(httpResponse.statusCode)" - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let error = json["error"] as? [String: Any], - let message = error["message"] as? String { - displayMessage = message - } - throw PluginChatError.apiError(displayMessage) + throw PluginChatError.apiError(Self.errorMessage(from: data, statusCode: httpResponse.statusCode)) } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], @@ -810,6 +804,49 @@ public struct PluginOpenAIChatHelper: Sendable { ) } + /// Extracts a human-readable error message from an OpenAI-compatible error body, + /// falling back to `HTTP ` when no message can be found. + /// + /// Most providers return `{"error": {"message": ...}}`, but some (notably + /// Google's Gemini OpenAI-compat endpoint) wrap the error in a top-level JSON + /// array: `[{"error": {"message": ...}}]`. Both shapes are handled here so the + /// descriptive message survives instead of being collapsed to `HTTP 404`. + static func errorMessage(from data: Data, statusCode: Int) -> String { + let json = try? JSONSerialization.jsonObject(with: data) + + let object: [String: Any]? + if let dictionary = json as? [String: Any] { + object = dictionary + } else if let array = json as? [Any], + let first = array.first as? [String: Any] { + object = first + } else { + object = nil + } + + if let object, let message = message(fromErrorObject: object) { + return message + } + return "HTTP \(statusCode)" + } + + /// Extracts a message from a single error object following the precedence used + /// across providers: top-level `detail`, then nested `error.message`, then a + /// top-level `message`. + private static func message(fromErrorObject object: [String: Any]) -> String? { + if let detail = object["detail"] as? String, !detail.isEmpty { + return detail + } + if let error = object["error"] as? [String: Any], + let message = error["message"] as? String, !message.isEmpty { + return message + } + if let message = object["message"] as? String, !message.isEmpty { + return message + } + return nil + } + func requestBody( model: String, systemPrompt: String, diff --git a/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/TypeWhisperPlugin.swift b/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/TypeWhisperPlugin.swift index 011b8976c..4c54ab89a 100644 --- a/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/TypeWhisperPlugin.swift +++ b/TypeWhisperPluginSDK/Sources/TypeWhisperPluginSDK/TypeWhisperPlugin.swift @@ -213,7 +213,14 @@ public protocol LLMProviderSetupStatusProviding { /// Optional protocol for LLM plugins that expose their selected model. /// Kept separate from LLMProviderPlugin to preserve binary compatibility with existing plugins. @objc public protocol LLMModelSelectable { + /// The model the user explicitly selected, or nil when no deliberate + /// selection exists. Hosts may treat this as a persistable preference. @objc optional var preferredModelId: String? { get } + /// The model the provider recommends when no explicit selection exists. + /// A transient fallback hint, not a user choice: hosts should prefer it + /// over picking from `supportedModels` themselves (whose ordering may put + /// a retired model first), but must not surface it as a user preference. + @objc optional var defaultModelId: String? { get } } // MARK: - Post-Processor Plugin diff --git a/TypeWhisperPluginSDK/Tests/TypeWhisperPluginSDKTests/OpenAIChatHelperTests.swift b/TypeWhisperPluginSDK/Tests/TypeWhisperPluginSDKTests/OpenAIChatHelperTests.swift index 1e82837fb..66c54200e 100644 --- a/TypeWhisperPluginSDK/Tests/TypeWhisperPluginSDKTests/OpenAIChatHelperTests.swift +++ b/TypeWhisperPluginSDK/Tests/TypeWhisperPluginSDKTests/OpenAIChatHelperTests.swift @@ -86,4 +86,65 @@ final class OpenAIChatHelperTests: XCTestCase { XCTAssertNil(requestBody["temperature"]) } + + // MARK: - Error body parsing + + func testErrorMessageParsesDictionaryBody() { + let data = Data(#"{"error":{"code":404,"message":"OpenAI says no"}}"#.utf8) + + let message = PluginOpenAIChatHelper.errorMessage(from: data, statusCode: 404) + + XCTAssertEqual(message, "OpenAI says no") + } + + func testErrorMessageParsesTopLevelArrayBody() { + // Gemini's OpenAI-compat endpoint wraps the error in a top-level array. + let data = Data( + """ + [{ + "error": { + "code": 404, + "message": "This model models/gemini-2.0-flash is no longer available.", + "status": "NOT_FOUND" + } + }] + """.utf8 + ) + + let message = PluginOpenAIChatHelper.errorMessage(from: data, statusCode: 404) + + XCTAssertEqual(message, "This model models/gemini-2.0-flash is no longer available.") + } + + func testErrorMessageFallsBackToStatusForUnparseableBody() { + let data = Data("not json".utf8) + + let message = PluginOpenAIChatHelper.errorMessage(from: data, statusCode: 404) + + XCTAssertEqual(message, "HTTP 404") + } + + func testErrorMessageFallsBackToStatusForEmptyArrayBody() { + let data = Data("[]".utf8) + + let message = PluginOpenAIChatHelper.errorMessage(from: data, statusCode: 503) + + XCTAssertEqual(message, "HTTP 503") + } + + func testErrorMessagePrefersTopLevelDetail() { + let data = Data(#"{"detail":"Invalid request payload"}"#.utf8) + + let message = PluginOpenAIChatHelper.errorMessage(from: data, statusCode: 422) + + XCTAssertEqual(message, "Invalid request payload") + } + + func testErrorMessageFallsBackToTopLevelMessage() { + let data = Data(#"{"message":"Something went wrong"}"#.utf8) + + let message = PluginOpenAIChatHelper.errorMessage(from: data, statusCode: 500) + + XCTAssertEqual(message, "Something went wrong") + } } diff --git a/TypeWhisperTests/APIRouterAndHandlersTests.swift b/TypeWhisperTests/APIRouterAndHandlersTests.swift index eb227e9c7..64dbeb8eb 100644 --- a/TypeWhisperTests/APIRouterAndHandlersTests.swift +++ b/TypeWhisperTests/APIRouterAndHandlersTests.swift @@ -6181,7 +6181,7 @@ final class APIRouterAndHandlersTests: XCTestCase { } @MainActor - func testGeminiPluginActivationIgnoresLegacyCacheAndRepairsInvalidSelection() throws { + func testGeminiPluginActivationIgnoresLegacyCacheAndDoesNotExposeInvalidSelection() throws { let appSupportDirectory = try TestSupport.makeTemporaryDirectory() defer { TestSupport.remove(appSupportDirectory) } @@ -6200,8 +6200,12 @@ final class APIRouterAndHandlersTests: XCTestCase { plugin.activate(host: host) XCTAssertEqual(plugin.supportedModels.map(\.id), ["gemini-flash-latest", "gemini-pro-latest", "gemini-flash-lite-latest"]) - XCTAssertEqual(plugin.selectedLLMModelId, "gemini-flash-latest") - XCTAssertEqual(host.userDefault(forKey: "selectedLLMModel") as? String, "gemini-flash-latest") + XCTAssertNil(host.userDefault(forKey: "fetchedLLMModels"), "legacy cache key must be cleared") + // A stored selection that is not in the current model list is neither + // exposed as a selection nor rewritten to a fallback; it stays + // persisted so it can re-validate once models are fetched again. + XCTAssertNil(plugin.selectedLLMModelId) + XCTAssertEqual(host.userDefault(forKey: "selectedLLMModel") as? String, "gemini-1.5-pro") } @MainActor diff --git a/TypeWhisperTests/PromptProcessingModelResolutionTests.swift b/TypeWhisperTests/PromptProcessingModelResolutionTests.swift new file mode 100644 index 000000000..6e6543686 --- /dev/null +++ b/TypeWhisperTests/PromptProcessingModelResolutionTests.swift @@ -0,0 +1,130 @@ +import Foundation +import XCTest +@testable import TypeWhisper + +@MainActor +final class PromptProcessingModelResolutionTests: XCTestCase { + private let models = ["gemini-2.0-flash", "gemini-2.5-flash", "gemini-flash-latest"] + + func testValidRequestedModelIsReturnedAndNotPersisted() { + let resolution = PromptProcessingService.resolveModel( + requestedModel: "gemini-2.5-flash", + preferredModelId: nil, + selectedCloudModel: "", + availableModelIds: models + ) + + XCTAssertEqual(resolution.modelId, "gemini-2.5-flash") + XCTAssertFalse(resolution.persistGlobally) + } + + func testPreferredModelIsResolvedAndPersistedWhenNothingSelected() { + let resolution = PromptProcessingService.resolveModel( + requestedModel: nil, + preferredModelId: "gemini-flash-latest", + selectedCloudModel: "", + availableModelIds: models + ) + + XCTAssertEqual(resolution.modelId, "gemini-flash-latest") + XCTAssertTrue(resolution.persistGlobally) + } + + func testAlphabeticalFallbackIsUsedButNeverPersisted() { + // Core of the bug: when nothing is selected and the provider plugin + // exposes no preference, the alphabetically-first (oldest) model must + // be used for this run but must NOT be written into the legacy global, + // or a retired model silently poisons every future run. + let resolution = PromptProcessingService.resolveModel( + requestedModel: nil, + preferredModelId: nil, + selectedCloudModel: "", + availableModelIds: models + ) + + XCTAssertEqual(resolution.modelId, "gemini-2.0-flash") + XCTAssertFalse(resolution.persistGlobally) + } + + func testProviderDefaultIsPreferredOverAlphabeticalFallback() { + // When nothing is selected, the provider's recommended default beats + // first-available — for Gemini the alphabetically-first model is the + // retired gemini-2.0-flash, which would 404 even transiently. + let resolution = PromptProcessingService.resolveModel( + requestedModel: nil, + preferredModelId: nil, + selectedCloudModel: "", + availableModelIds: models, + providerDefaultModelId: "gemini-flash-latest" + ) + + XCTAssertEqual(resolution.modelId, "gemini-flash-latest") + XCTAssertFalse(resolution.persistGlobally) + } + + func testProviderDefaultNotInAvailableModelsIsIgnored() { + let resolution = PromptProcessingService.resolveModel( + requestedModel: nil, + preferredModelId: nil, + selectedCloudModel: "", + availableModelIds: models, + providerDefaultModelId: "not-a-listed-model" + ) + + XCTAssertEqual(resolution.modelId, "gemini-2.0-flash") + XCTAssertFalse(resolution.persistGlobally) + } + + func testInvalidNonEmptyGlobalIsRepairedToFallbackAndPersisted() { + // A non-empty global that is no longer valid is self-healed to a valid + // model and persisted, so the stale value is not retried forever. + let resolution = PromptProcessingService.resolveModel( + requestedModel: "retired-model", + preferredModelId: nil, + selectedCloudModel: "retired-model", + availableModelIds: models + ) + + XCTAssertEqual(resolution.modelId, "gemini-2.0-flash") + XCTAssertTrue(resolution.persistGlobally) + } + + func testInvalidNonEmptyGlobalIsRepairedToProviderDefault() { + // Self-healing must repair to the provider's recommended default when + // one exists, not adopt (and persist) the retired oldest model. + let resolution = PromptProcessingService.resolveModel( + requestedModel: "retired-model", + preferredModelId: nil, + selectedCloudModel: "retired-model", + availableModelIds: models, + providerDefaultModelId: "gemini-flash-latest" + ) + + XCTAssertEqual(resolution.modelId, "gemini-flash-latest") + XCTAssertTrue(resolution.persistGlobally) + } + + func testValidSelectedCloudModelIsKeptWithoutRepersisting() { + let resolution = PromptProcessingService.resolveModel( + requestedModel: nil, + preferredModelId: nil, + selectedCloudModel: "gemini-2.5-flash", + availableModelIds: models + ) + + XCTAssertEqual(resolution.modelId, "gemini-2.5-flash") + XCTAssertFalse(resolution.persistGlobally) + } + + func testNoAvailableModelsReturnsRequestedModelWithoutPersisting() { + let resolution = PromptProcessingService.resolveModel( + requestedModel: "anything", + preferredModelId: "preferred", + selectedCloudModel: "global", + availableModelIds: [] + ) + + XCTAssertEqual(resolution.modelId, "anything") + XCTAssertFalse(resolution.persistGlobally) + } +}