Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
81 changes: 60 additions & 21 deletions TypeWhisper/Services/PromptProcessingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -367,33 +367,72 @@ class PromptProcessingService: ObservableObject {
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)
)

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]
) -> 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)
}

return fallbackModelId
if !selectedCloudModel.isEmpty, validIds.contains(selectedCloudModel) {
return ModelResolution(modelId: selectedCloudModel, persistGlobally: false)
}

// Fall back to the first available model for this run. Persist it only to
// repair a non-empty global that is no longer valid (self-healing); when
// no model was ever selected, use the fallback transiently without
// poisoning the global with the oldest model.
let isRepairingInvalidSelection = !selectedCloudModel.isEmpty
return ModelResolution(
modelId: availableModelIds.first,
persistGlobally: isRepairingInvalidSelection
)
}
}
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 Down Expand Up @@ -108,6 +108,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,19 @@
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)

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 Down Expand Up @@ -86,6 +86,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,19 @@
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)

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(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"

Expand Down Expand Up @@ -212,6 +212,7 @@ final class FireworksPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTerm
}

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,19 @@
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)

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(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"
Expand Down Expand Up @@ -117,6 +117,7 @@ final class GeminiPlugin: 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,19 @@
import Foundation
import XCTest
import TypeWhisperPluginSDK
@_spi(Testing) import TypeWhisperPluginSDKTesting
@testable import GeminiPlugin

final class GeminiPluginTests: XCTestCase {
func testPreferredModelIdReflectsSelectedLLMModel() throws {
let host = try PluginTestHostServices()
let plugin = GeminiPlugin()
plugin.activate(host: host)

let target = try XCTUnwrap(plugin.supportedModels.first?.id)
plugin.selectLLMModel(target)

let preferred = (plugin as? LLMModelSelectable)?.preferredModelId
XCTAssertEqual(preferred, target)
}
}
3 changes: 2 additions & 1 deletion TypeWhisperPluginSDK/Plugins/GroqPlugin/GroqPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
static let pluginId = "com.typewhisper.groq"
static let pluginName = "Groq"
private static let transcriptionRequestTimeout: TimeInterval = 600
Expand Down Expand Up @@ -169,6 +169,7 @@ final class GroqPlugin: NSObject, TranscriptionEnginePlugin, DictionaryTermsCapa
}

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
Expand Up @@ -46,6 +46,18 @@ final class GroqPluginTests: XCTestCase {
XCTAssertFalse(bodyText.contains(#"filename="audio.wav""#))
}

func testPreferredModelIdReflectsSelectedLLMModel() throws {
let host = try PluginTestHostServices()
let plugin = GroqPlugin()
plugin.activate(host: host)

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)!,
Expand Down
Loading