Skip to content
Open
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
14 changes: 12 additions & 2 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
.store(in: &cancellables)

suggestionSettings.$selectedEngine
// The fallback switches decide whether the local model stays loaded under Apple Intelligence,
// so they re-evaluate the runtime just like an engine change does.
Publishers.CombineLatest3(
suggestionSettings.$selectedEngine.removeDuplicates(),
suggestionSettings.$isAppleLanguageFallbackEnabled.removeDuplicates(),
suggestionSettings.$keepsFallbackModelLoaded.removeDuplicates()
)
.dropFirst()
.removeDuplicates()
.sink { [weak self] _ in
self?.startRuntimeIfPreferredEngineRequiresIt()
}
Expand Down Expand Up @@ -284,6 +289,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
switch suggestionSettings.selectedEngine {
case .llamaOpenSource:
runtimeModel.startIfNeeded()
case .appleIntelligence
where suggestionSettings.isAppleLanguageFallbackEnabled && suggestionSettings.keepsFallbackModelLoaded:
// The user chose to keep the fallback model ready, so an unsupported language gets a
// suggestion without first waiting several seconds for the model to load.
runtimeModel.startIfNeeded()
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.
Expand Down
15 changes: 15 additions & 0 deletions Cotabby/Models/Settings/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ struct SuggestionEngineSettings: Equatable {
var pluggedInEngine: SuggestionEngineKind
var pluggedInModelFilename: String
var pluggedInEndpointModelName: String
/// When Apple Intelligence rejects a language, retry with the selected Open Source model.
var isAppleLanguageFallbackEnabled: Bool
/// Keep that fallback model loaded while Apple Intelligence is selected, so the first
/// fallback suggestion does not wait for the model to load. Costs the model's memory.
var keepsFallbackModelLoaded: Bool
}

/// Completion length, timing, streaming, and acceptance behavior.
Expand Down Expand Up @@ -185,6 +190,16 @@ extension SuggestionSettingsData {
set { engine.isPowerBasedModelSwitchingEnabled = newValue }
}

var isAppleLanguageFallbackEnabled: Bool {
get { engine.isAppleLanguageFallbackEnabled }
set { engine.isAppleLanguageFallbackEnabled = newValue }
}

var keepsFallbackModelLoaded: Bool {
get { engine.keepsFallbackModelLoaded }
set { engine.keepsFallbackModelLoaded = newValue }
}

var batteryEngine: SuggestionEngineKind {
get { engine.batteryEngine }
set { engine.batteryEngine = newValue }
Expand Down
25 changes: 24 additions & 1 deletion Cotabby/Models/Settings/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,11 @@ final class SuggestionSettingsModel: ObservableObject {
@Published private(set) var perAppShortcutOverrides: [PerAppShortcutOverride]
@Published private(set) var acceptanceGranularity: AcceptanceGranularity
@Published private(set) var isPowerBasedModelSwitchingEnabled: Bool
/// Retry with the Open Source model when Apple Intelligence rejects the text's language.
/// Read live by `SuggestionEngineRouter` at request time.
@Published private(set) var isAppleLanguageFallbackEnabled: Bool
/// Keep the fallback model loaded while Apple Intelligence is selected (see `AppDelegate`).
@Published private(set) var keepsFallbackModelLoaded: Bool
@Published private(set) var batteryEngine: SuggestionEngineKind
@Published private(set) var batteryModelFilename: String
@Published private(set) var batteryEndpointModelName: String
Expand Down Expand Up @@ -286,6 +291,8 @@ final class SuggestionSettingsModel: ObservableObject {
perAppShortcutOverrides = data.perAppShortcutOverrides
acceptanceGranularity = data.acceptanceGranularity
isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled
isAppleLanguageFallbackEnabled = data.isAppleLanguageFallbackEnabled
keepsFallbackModelLoaded = data.keepsFallbackModelLoaded
batteryEngine = data.batteryEngine
batteryModelFilename = data.batteryModelFilename
batteryEndpointModelName = data.batteryEndpointModelName
Expand Down Expand Up @@ -369,6 +376,8 @@ final class SuggestionSettingsModel: ObservableObject {
perAppShortcutOverrides = data.perAppShortcutOverrides
acceptanceGranularity = data.acceptanceGranularity
isPowerBasedModelSwitchingEnabled = data.isPowerBasedModelSwitchingEnabled
isAppleLanguageFallbackEnabled = data.isAppleLanguageFallbackEnabled
keepsFallbackModelLoaded = data.keepsFallbackModelLoaded
batteryEngine = data.batteryEngine
batteryModelFilename = data.batteryModelFilename
batteryEndpointModelName = data.batteryEndpointModelName
Expand Down Expand Up @@ -410,7 +419,9 @@ final class SuggestionSettingsModel: ObservableObject {
batteryEndpointModelName: batteryEndpointModelName,
pluggedInEngine: pluggedInEngine,
pluggedInModelFilename: pluggedInModelFilename,
pluggedInEndpointModelName: pluggedInEndpointModelName
pluggedInEndpointModelName: pluggedInEndpointModelName,
isAppleLanguageFallbackEnabled: isAppleLanguageFallbackEnabled,
keepsFallbackModelLoaded: keepsFallbackModelLoaded
),
completion: SuggestionCompletionSettings(
selectedWordCountPreset: selectedWordCountPreset,
Expand Down Expand Up @@ -571,6 +582,18 @@ final class SuggestionSettingsModel: ObservableObject {
endpointCredentialRevision &+= 1
}

func setAppleLanguageFallbackEnabled(_ enabled: Bool) {
guard isAppleLanguageFallbackEnabled != enabled else { return }
isAppleLanguageFallbackEnabled = enabled
store.saveAppleLanguageFallbackEnabled(enabled)
}

func setKeepsFallbackModelLoaded(_ enabled: Bool) {
guard keepsFallbackModelLoaded != enabled else { return }
keepsFallbackModelLoaded = enabled
store.saveKeepsFallbackModelLoaded(enabled)
}

func setPowerBasedModelSwitchingEnabled(_ enabled: Bool) {
guard isPowerBasedModelSwitchingEnabled != enabled else {
return
Expand Down
12 changes: 12 additions & 0 deletions Cotabby/Services/Runtime/SuggestionEngineRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@ final class SuggestionEngineRouter {
recordQualityOutcome(result)
return result
} catch SuggestionClientError.unsupportedLanguageOrLocale(let message) {
// The user can turn the fallback off (Engine & Model → Apple Intelligence). Then an
// unsupported language simply gets no suggestion, and the local model never loads.
guard suggestionSettings.isAppleLanguageFallbackEnabled else {
CotabbyLogger.suggestion.info(
"Apple Intelligence unsupported for locale; fallback is turned off",
metadata: metadata.merging(["reason": .string(message)]) { _, new in new }
)
return SuggestionResult(
generation: request.generation, rawText: "", text: "", latency: 0,
suppressionReason: "appleLanguageUnsupported"
)
Comment on lines +72 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unsupported languages go uncounted

When Apple Intelligence rejects a language and fallback is off, this return skips the router's quality accounting. The coordinator also skips counting it because the result has a suppression reason. As a result, these requests are missing from both the generated count and the appleLanguageUnsupported suppression count.

Suggested change
return SuggestionResult(
generation: request.generation, rawText: "", text: "", latency: 0,
suppressionReason: "appleLanguageUnsupported"
)
let result = SuggestionResult(
generation: request.generation, rawText: "", text: "", latency: 0,
suppressionReason: "appleLanguageUnsupported"
)
recordQualityOutcome(result)
return result

Fix in Codex Fix in Claude Code

}
CotabbyLogger.suggestion.info(
"Apple Intelligence unsupported for locale, falling back to open-source: \(message)",
metadata: metadata.merging([
Expand Down
6 changes: 5 additions & 1 deletion Cotabby/Support/Runtime/BundledRuntimeLocator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,12 @@ struct BundledRuntimeLocator {
/// them out of the model picker.
static func discoverGGUFModelURLs(in directoryURL: URL, maxDepth: Int = 4) -> [URL] {
let fileManager = FileManager.default
// `FileManager.enumerator` does not descend into a root that is itself a symbolic link, so a
// models folder linked to another location (an external drive, another app's folder) would
// list nothing. Resolve the root first; links deeper inside stay unfollowed, which keeps
// the bounded walk from escaping into arbitrary trees.
guard let enumerator = fileManager.enumerator(
at: directoryURL,
at: directoryURL.resolvingSymlinksInPath(),
includingPropertiesForKeys: [.isRegularFileKey],
options: [.skipsHiddenFiles, .skipsPackageDescendants]
) else {
Expand Down
23 changes: 22 additions & 1 deletion Cotabby/Support/Settings/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,8 @@ struct SuggestionSettingsStore {
private static let globalToggleKeyLabelDefaultsKey = "cotabbyGlobalToggleKeyLabel"
private static let acceptanceGranularityDefaultsKey = "cotabbyAcceptanceGranularity"

private static let appleLanguageFallbackEnabledDefaultsKey = "cotabbyAppleLanguageFallbackEnabled"
private static let keepFallbackModelLoadedDefaultsKey = "cotabbyKeepFallbackModelLoaded"
private static let powerModelSwitchingEnabledDefaultsKey = "cotabbyPowerBasedModelSwitchingEnabled"
private static let batteryEngineDefaultsKey = "cotabbyBatteryEngine"
private static let batteryModelFilenameDefaultsKey = "cotabbyBatteryModelFilename"
Expand Down Expand Up @@ -270,6 +272,8 @@ struct SuggestionSettingsStore {
globalToggleKeyLabelDefaultsKey,
acceptanceGranularityDefaultsKey,
powerModelSwitchingEnabledDefaultsKey,
appleLanguageFallbackEnabledDefaultsKey,
keepFallbackModelLoadedDefaultsKey,
batteryEngineDefaultsKey,
batteryModelFilenameDefaultsKey,
batteryEndpointModelNameDefaultsKey,
Expand Down Expand Up @@ -549,6 +553,11 @@ struct SuggestionSettingsStore {

let resolvedPowerBasedModelSwitchingEnabled =
userDefaults.object(forKey: Self.powerModelSwitchingEnabledDefaultsKey) as? Bool ?? false
// On by default: falling back is what Cotabby has always done for unsupported languages.
let resolvedAppleLanguageFallbackEnabled =
userDefaults.object(forKey: Self.appleLanguageFallbackEnabledDefaultsKey) as? Bool ?? true
let resolvedKeepsFallbackModelLoaded =
userDefaults.object(forKey: Self.keepFallbackModelLoadedDefaultsKey) as? Bool ?? false
let resolvedBatteryEngine = userDefaults.string(forKey: Self.batteryEngineDefaultsKey)
.flatMap(SuggestionEngineKind.init(rawValue:)) ?? .llamaOpenSource
let resolvedBatteryModelFilename = userDefaults.string(forKey: Self.batteryModelFilenameDefaultsKey) ?? ""
Expand Down Expand Up @@ -580,7 +589,9 @@ struct SuggestionSettingsStore {
batteryEndpointModelName: resolvedBatteryEndpointModelName,
pluggedInEngine: resolvedPluggedInEngine,
pluggedInModelFilename: resolvedPluggedInModelFilename,
pluggedInEndpointModelName: resolvedPluggedInEndpointModelName
pluggedInEndpointModelName: resolvedPluggedInEndpointModelName,
isAppleLanguageFallbackEnabled: resolvedAppleLanguageFallbackEnabled,
keepsFallbackModelLoaded: resolvedKeepsFallbackModelLoaded
),
completion: SuggestionCompletionSettings(
selectedWordCountPreset: resolvedWordCountPreset,
Expand Down Expand Up @@ -724,6 +735,8 @@ struct SuggestionSettingsStore {
savePerAppShortcutOverrides(data.perAppShortcutOverrides)
saveAcceptanceGranularity(data.acceptanceGranularity)
savePowerBasedModelSwitchingEnabled(data.isPowerBasedModelSwitchingEnabled)
saveAppleLanguageFallbackEnabled(data.isAppleLanguageFallbackEnabled)
saveKeepsFallbackModelLoaded(data.keepsFallbackModelLoaded)
saveBatteryEngine(data.batteryEngine)
saveBatteryModelFilename(data.batteryModelFilename)
saveBatteryEndpointModelName(data.batteryEndpointModelName)
Expand Down Expand Up @@ -851,6 +864,14 @@ struct SuggestionSettingsStore {
userDefaults.set(mode.rawValue, forKey: Self.openAICompatibleAPIModeDefaultsKey)
}

func saveAppleLanguageFallbackEnabled(_ enabled: Bool) {
userDefaults.set(enabled, forKey: Self.appleLanguageFallbackEnabledDefaultsKey)
}

func saveKeepsFallbackModelLoaded(_ enabled: Bool) {
userDefaults.set(enabled, forKey: Self.keepFallbackModelLoadedDefaultsKey)
}

func savePowerBasedModelSwitchingEnabled(_ enabled: Bool) {
userDefaults.set(enabled, forKey: Self.powerModelSwitchingEnabledDefaultsKey)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
import SwiftUI

/// Apple Intelligence availability presentation.
Expand All @@ -23,6 +24,64 @@ extension EngineAndModelPaneView {
)
}
.settingsItem(.appleIntelligenceAvailability)

Toggle(isOn: Binding(
get: { suggestionSettings.isAppleLanguageFallbackEnabled },
set: { suggestionSettings.setAppleLanguageFallbackEnabled($0) }
)) {
SettingsRowLabel(
title: "Fall Back to Open Source Model",
description: "When Apple Intelligence doesn't support the language you're writing in, " +
"suggest with \(fallbackModelName) instead. Turn off to get no suggestion in those languages.",
systemImage: "arrow.triangle.branch"
)
}
.settingsItem(.appleLanguageFallback)

Toggle(isOn: Binding(
get: { suggestionSettings.keepsFallbackModelLoaded },
set: { suggestionSettings.setKeepsFallbackModelLoaded($0) }
)) {
SettingsRowLabel(
title: "Keep Fallback Model Loaded",
description: "Load the fallback model in advance so its first suggestion doesn't wait for it " +
"to load. Uses the model's memory (several GB) while Apple Intelligence is selected.",
systemImage: "memorychip"
)
}
.disabled(!suggestionSettings.isAppleLanguageFallbackEnabled)
Comment on lines +41 to +52

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Keep-loaded search targets wrong switch

“Keep Fallback Model Loaded” has no search item or row anchor. Its search terms, including “keep loaded” and “preload,” belong to the fallback switch's item, so searching for the new setting highlights the other switch instead. Give the keep-loaded switch its own indexed item and anchor so search can take users directly to it.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code


// The Open Source section is hidden while Apple Intelligence is the engine, so the
// fallback model is chosen here. It is the same selection the Open Source engine uses:
// the local runtime holds one model at a time.
if runtimeModel.availableModels.isEmpty {
Text("No downloaded models were found, so there is nothing to fall back to. " +
"Switch the engine to Open Source to download one.")
.font(.caption)
.foregroundStyle(.orange)
} else {
Picker(selection: selectedModelBinding) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Picker loads fallback model

When “Keep Fallback Model Loaded” is off, changing this picker still calls runtimeModel.selectModel, which prepares and loads the selected GGUF. The model can then remain in memory while Apple Intelligence is selected, despite the user choosing not to keep it loaded.

Fix in Codex Fix in Claude Code

ForEach(runtimeModel.availableModels) { model in
Text(model.displayName).tag(model.filename)
}
} label: {
SettingsRowLabel(
title: "Fallback Model",
description: "The downloaded model used when Apple Intelligence can't handle the " +
"language. It is also your Open Source engine's model.",
systemImage: "shippingbox"
)
}
.disabled(!suggestionSettings.isAppleLanguageFallbackEnabled
|| suggestionSettings.isPowerBasedModelSwitchingEnabled)
.settingsItem(.appleLanguageFallbackModel)
}
}
}

/// The model the fallback uses: the selected Open Source model. The local runtime holds one
/// model at a time, so the fallback cannot use a different one without swapping it in.
private var fallbackModelName: String {
runtimeModel.selectedModelFilename.map { ($0 as NSString).deletingPathExtension } ?? "your Open Source model"
}
}
15 changes: 14 additions & 1 deletion Cotabby/UI/Settings/SettingsIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ enum SettingsItem: String, CaseIterable, Identifiable {
// Engine & Model
case engine
case appleIntelligenceAvailability
case appleLanguageFallback
case appleLanguageFallbackModel
case modelStatus
case selectedModel
case lowPowerModeAutoDisable
Expand Down Expand Up @@ -152,6 +154,8 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .contextLivePreview: return "Live Preview"
case .engine: return "Engine"
case .appleIntelligenceAvailability: return "Apple Intelligence Availability"
case .appleLanguageFallback: return "Fall Back to Open Source Model"
case .appleLanguageFallbackModel: return "Fallback Model"
case .modelStatus: return "Model Status"
case .selectedModel: return "Selected Model"
case .lowPowerModeAutoDisable: return "Pause in Low Power Mode"
Expand Down Expand Up @@ -236,6 +240,8 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .contextLivePreview: return "text.cursor"
case .engine: return "cpu"
case .appleIntelligenceAvailability: return "apple.logo"
case .appleLanguageFallback: return "arrow.triangle.branch"
case .appleLanguageFallbackModel: return "shippingbox"
case .modelStatus: return "info.circle"
case .selectedModel: return "shippingbox"
case .lowPowerModeAutoDisable: return "bolt.slash.circle"
Expand Down Expand Up @@ -293,7 +299,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
return .writing
case .extendedContext, .contextLivePreview:
return .context
case .engine, .appleIntelligenceAvailability, .modelStatus, .selectedModel,
case .engine, .appleIntelligenceAvailability, .appleLanguageFallback, .appleLanguageFallbackModel, .modelStatus, .selectedModel,
.lowPowerModeAutoDisable, .powerBasedModelSwitching, .batteryModel, .pluggedInModel,
.downloadModels, .huggingFaceBrowser, .modelsFolder, .lmStudio,
.endpointBaseURL, .endpointAPIMode, .endpointAPIKey, .endpointStatus, .endpointModel:
Expand Down Expand Up @@ -363,6 +369,8 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .contextLivePreview: return "A real field that exercises the full pipeline."
case .engine: return "Apple Intelligence, bundled Open Source, or a local endpoint."
case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence."
case .appleLanguageFallback: return "Use the local model for languages Apple Intelligence doesn't support."
case .appleLanguageFallbackModel: return "Which downloaded model the language fallback uses."
case .modelStatus: return "Whether the local model is loaded and ready."
case .selectedModel: return "Which downloaded model generates suggestions."
case .lowPowerModeAutoDisable: return "Pause suggestions while Low Power Mode is active."
Expand Down Expand Up @@ -537,6 +545,11 @@ enum SettingsItem: String, CaseIterable, Identifiable {
"provider", "runtime", "foundation models", "oss", "local", "endpoint",
"ollama", "openai compatible", "lm studio", "vllm",
"on-device", "model engine"]
case .appleLanguageFallbackModel:
return ["fallback model", "fallback", "model", "gemma", "gguf", "local model", "choose model"]
case .appleLanguageFallback:
return ["fallback", "fall back", "unsupported language", "language", "macedonian",
"open source", "local model", "gemma", "llama", "keep loaded", "preload", "memory"]
case .appleIntelligenceAvailability:
return ["apple intelligence", "availability", "available", "supported",
"compatibility", "status", "macos", "device support"]
Expand Down
Loading