Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions TypeWhisper.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -806,6 +807,7 @@
BDF548E65340A17A3A16590B /* PluginManifestValidationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginManifestValidationTests.swift; sourceTree = "<group>"; };
BE6B6611B899F649B097A726 /* SnippetServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SnippetServiceTests.swift; sourceTree = "<group>"; };
B2D4F6A8C0E2143F9B7D5C1A /* PromptActionTemperaturePersistenceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PromptActionTemperaturePersistenceTests.swift; sourceTree = "<group>"; };
FEEDFACE00000000000000B1 /* PromptProcessingModelResolutionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PromptProcessingModelResolutionTests.swift; sourceTree = "<group>"; };
CE5852D6767C5FA955B84C52 /* AppFormatterServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppFormatterServiceTests.swift; sourceTree = "<group>"; };
CE5852D6767C5FA955B84C53 /* SpeechPunctuationServiceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SpeechPunctuationServiceTests.swift; sourceTree = "<group>"; };
50756E40757C6BC13C52ED74 /* NumberWordNormalizerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NumberWordNormalizerTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
93 changes: 72 additions & 21 deletions TypeWhisper/Services/PromptProcessingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
30 changes: 26 additions & 4 deletions TypeWhisper/Views/PromptActionsSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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] {
Expand All @@ -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)
}
Expand All @@ -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 ?? ""
}
}

Expand Down
76 changes: 76 additions & 0 deletions TypeWhisperPluginSDK/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading