From e39ea46fff85c321f830529bee68ea17c90844f0 Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:46:18 -0400 Subject: [PATCH 1/2] Add Apple Intelligence completion model --- .gitignore | 2 +- KeyType.xcodeproj/project.pbxproj | 4 + KeyType/App/AppDelegate.swift | 8 +- KeyType/App/KeyTypeApp.swift | 5 +- .../AppleIntelligenceCompletionEngine.swift | 301 ++++++++++++++++++ .../Completion/CompletionController.swift | 63 ++-- .../Logic/Models/ModelSetupCoordinator.swift | 291 +++++++++++++++++ KeyType/Logic/Settings/SettingsStore.swift | 58 ++++ .../Telemetry/LatencyExportContext.swift | 12 +- .../Settings/Sections/ModelSettingsView.swift | 37 ++- KeyType/Views/Setup/OnboardingView.swift | 81 ++++- KeyTypeTests/KeyTypeTests.swift | 233 ++++++++++++++ .../Personalization/LatencyExport.swift | 12 +- .../PersonalizationTests.swift | 1 + README.md | 15 +- docs/00-overview.md | 4 +- docs/01-architecture.md | 7 + docs/05-decisions.md | 26 ++ 18 files changed, 1114 insertions(+), 46 deletions(-) create mode 100644 KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift create mode 100644 KeyType/Logic/Models/ModelSetupCoordinator.swift diff --git a/.gitignore b/.gitignore index 15db6ca..d414b53 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ Packages/ModelRuntime/Vendor/ # Model weights (stay in ~/Library/Application Support/KeyType/Models, never in the repo) *.gguf -Models/ +/Models/ # Completion benchmark local/private data and generated reports KeyTypeBench-20260603/Private/ diff --git a/KeyType.xcodeproj/project.pbxproj b/KeyType.xcodeproj/project.pbxproj index de1601f..e37f19c 100644 --- a/KeyType.xcodeproj/project.pbxproj +++ b/KeyType.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 4B5450012FC9600000000036 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000037 /* MenuBarView.swift */; }; 4B5450012FC9600000000038 /* ContextCaptureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000039 /* ContextCaptureController.swift */; }; 4B5450012FC9600000000041 /* CompletionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000040 /* CompletionController.swift */; }; + 4B5450012FC9600000000601 /* AppleIntelligenceCompletionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000600 /* AppleIntelligenceCompletionEngine.swift */; }; 4B5450012FC9600000000043 /* CompletionAcceptanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000042 /* CompletionAcceptanceController.swift */; }; 4B5450012FC9600000000045 /* FieldFontResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000044 /* FieldFontResolver.swift */; }; 4B5450012FC9600000000047 /* SystemWordRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B5450012FC9600000000046 /* SystemWordRecognizer.swift */; }; @@ -106,6 +107,7 @@ 4B5450012FC9600000000037 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; 4B5450012FC9600000000039 /* ContextCaptureController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextCaptureController.swift; sourceTree = ""; }; 4B5450012FC9600000000040 /* CompletionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionController.swift; sourceTree = ""; }; + 4B5450012FC9600000000600 /* AppleIntelligenceCompletionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleIntelligenceCompletionEngine.swift; sourceTree = ""; }; 4B5450012FC9600000000042 /* CompletionAcceptanceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionAcceptanceController.swift; sourceTree = ""; }; 4B5450012FC9600000000044 /* FieldFontResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldFontResolver.swift; sourceTree = ""; }; 4B5450012FC9600000000046 /* SystemWordRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemWordRecognizer.swift; sourceTree = ""; }; @@ -301,6 +303,7 @@ isa = PBXGroup; children = ( 4B5450012FC9600000000080 /* AcceptanceShortcut.swift */, + 4B5450012FC9600000000600 /* AppleIntelligenceCompletionEngine.swift */, 4B5450012FC9600000000042 /* CompletionAcceptanceController.swift */, 4B5450012FC9600000000040 /* CompletionController.swift */, 4B5450012FC96000000002A0 /* CompletionPromotionCache.swift */, @@ -647,6 +650,7 @@ 4B5450012FC9600000000032 /* PermissionsManager.swift in Sources */, 4B5450012FC9600000000038 /* ContextCaptureController.swift in Sources */, 4B5450012FC96000000000B0 /* ScreenContextController.swift in Sources */, + 4B5450012FC9600000000601 /* AppleIntelligenceCompletionEngine.swift in Sources */, 4B5450012FC9600000000041 /* CompletionController.swift in Sources */, 4B5450012FC96000000002A1 /* CompletionPromotionCache.swift in Sources */, 4B5450012FC96000000002A3 /* CompletionReuseHistory.swift in Sources */, diff --git a/KeyType/App/AppDelegate.swift b/KeyType/App/AppDelegate.swift index 807184e..6b4f1e9 100644 --- a/KeyType/App/AppDelegate.swift +++ b/KeyType/App/AppDelegate.swift @@ -130,11 +130,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { acceptance.completionController = completion acceptance.correctionController = correction acceptance.settings = settings - // When a model finishes setup (GGUF + ACPF both present), make it the selected model and - // reload the engine so the change takes effect without a relaunch. + // When a model finishes setup (GGUF + ACPF both present), select and reload it only while + // the user is on the local-model provider. A background local download must not silently + // replace an active Apple Intelligence selection. modelSetup.onModelReady = { [weak self] filename in guard let self else { return } - self.settings.selectedModelFilename = filename + guard case .local = self.settings.selectedCompletionModel else { return } + self.settings.selectedCompletionModel = .local(filename: filename) self.completion.reloadModel() } // Import failures (an incompatible GGUF, a copy/profile error) are shown as a modal alert the diff --git a/KeyType/App/KeyTypeApp.swift b/KeyType/App/KeyTypeApp.swift index f727488..0a8125c 100644 --- a/KeyType/App/KeyTypeApp.swift +++ b/KeyType/App/KeyTypeApp.swift @@ -27,7 +27,10 @@ struct KeyTypeApp: App { Window("KeyType", id: AppDelegate.onboardingWindowID) { OnboardingView( permissionGuidance: appDelegate.permissionGuidance, - markCompleted: { appDelegate.markOnboardingCompleted() } + markCompleted: { + appDelegate.markOnboardingCompleted() + appDelegate.completion.reloadModel() + } ) .environment(appDelegate.permissions) .environment(appDelegate.settings) diff --git a/KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift b/KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift new file mode 100644 index 0000000..d010994 --- /dev/null +++ b/KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift @@ -0,0 +1,301 @@ +// +// AppleIntelligenceCompletionEngine.swift +// KeyType +// +// Higher-level completion-engine seam plus the Apple Intelligence Foundation Models adapter. +// Foundation Models returns generated text rather than tokenizer logits, so it belongs behind +// `CompletionGenerating` and deliberately does not pretend to be a `LocalModelRuntime`. +// + +import AutocompleteCore +import ConstrainedGeneration +import Foundation + +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// Lifecycle operations the app needs in addition to the shared `CompletionGenerating` contract. +/// The llama constrained decoder already implements all four methods; text-only providers can use +/// the conservative defaults for warmup, teardown, and correction validation. +protocol CompletionEngine: CompletionGenerating { + nonisolated var supportsTokenHealing: Bool { get } + nonisolated var maxPromptTokens: Int { get } + nonisolated func warmUp(for request: CompletionRequest) async throws + nonisolated func shutdown() async + nonisolated func validateCorrectionCandidates( + _ candidates: [CorrectionCandidate], + prefixBeforeWord: String, + suffixWindow: String, + priorPredictionReplacement: String?, + thresholds: CorrectionValidationThresholds + ) async throws -> [CorrectionCandidate] +} + +extension CompletionEngine { + nonisolated var supportsTokenHealing: Bool { false } + nonisolated var maxPromptTokens: Int { 600 } + nonisolated func warmUp(for request: CompletionRequest) async throws {} + nonisolated func shutdown() async {} + + /// Foundation Models does not expose the token logits used by KeyType's correction scorer. + /// Preserve the system spell/grammar candidates unchanged instead of fabricating a score. + nonisolated func validateCorrectionCandidates( + _ candidates: [CorrectionCandidate], + prefixBeforeWord: String, + suffixWindow: String, + priorPredictionReplacement: String?, + thresholds: CorrectionValidationThresholds + ) async throws -> [CorrectionCandidate] { + candidates.filter { $0.source == .spellcheckOnly } + } +} + +extension ConstrainedGenerationEngine: CompletionEngine { + nonisolated var supportsTokenHealing: Bool { true } + nonisolated var maxPromptTokens: Int { 4_096 } +} + +enum AppleIntelligenceModelAvailability: Equatable, Sendable { + case available + case requiresMacOS26 + case frameworkUnavailable + case deviceNotEligible + case appleIntelligenceNotEnabled + case modelNotReady + case unsupportedLocale + case unavailable + + nonisolated var isAvailable: Bool { + if case .available = self { return true } + return false + } + + nonisolated var message: String { + switch self { + case .available: + return "Ready — completions stay on this Mac." + case .requiresMacOS26: + return "Requires macOS 26 or later." + case .frameworkUnavailable: + return "This build does not include Apple's Foundation Models framework." + case .deviceNotEligible: + return "Apple Intelligence requires an eligible Apple silicon Mac." + case .appleIntelligenceNotEnabled: + return "Turn on Apple Intelligence in System Settings to use this model." + case .modelNotReady: + return "Apple Intelligence is still downloading or preparing its on-device model." + case .unsupportedLocale: + return "Apple Intelligence does not support the current language or locale." + case .unavailable: + return "Apple Intelligence is currently unavailable." + } + } +} + +enum AppleIntelligenceModelSupport { + nonisolated static var availability: AppleIntelligenceModelAvailability { + guard #available(macOS 26.0, *) else { return .requiresMacOS26 } +#if canImport(FoundationModels) + return availableModelStatus() +#else + return .frameworkUnavailable +#endif + } + +#if canImport(FoundationModels) + @available(macOS 26.0, *) + nonisolated private static func availableModelStatus() -> AppleIntelligenceModelAvailability { + let model = SystemLanguageModel.default + switch model.availability { + case .available: + return model.supportsLocale() ? .available : .unsupportedLocale + case .unavailable(.deviceNotEligible): + return .deviceNotEligible + case .unavailable(.appleIntelligenceNotEnabled): + return .appleIntelligenceNotEnabled + case .unavailable(.modelNotReady): + return .modelNotReady + @unknown default: + return .unavailable + } + } +#endif +} + +enum AppleIntelligenceCompletionError: Error, LocalizedError { + case unavailable(AppleIntelligenceModelAvailability) + + nonisolated var errorDescription: String? { + switch self { + case .unavailable(let availability): + return "Siri AI (Apple Intelligence) is unavailable. \(availability.message)" + } + } +} + +/// A framework-independent engine whose responder is injectable. Production injects a fresh +/// `LanguageModelSession` per keystroke; tests inject a deterministic closure and need no real +/// Apple Intelligence model, hardware eligibility, or network access. +final class AppleIntelligenceCompletionEngine: CompletionEngine, @unchecked Sendable { + /// A byte cap is deliberately stricter than the prompt builder's English-centric chars/token + /// estimate. Even if every UTF-8 byte became its own token, the prompt plus system instructions + /// and short response remain below Foundation Models' 4,096-token context window. + nonisolated static let maxPromptUTF8Bytes = 2_600 + nonisolated static let boundaryMarkerTokenAllowance = 4 + + typealias Responder = @Sendable ( + _ prompt: String, + _ instructions: String, + _ maximumResponseTokens: Int + ) async throws -> String + + private nonisolated let respond: Responder + + nonisolated init(respond: @escaping Responder) { + self.respond = respond + } + + nonisolated func completions(for request: CompletionRequest) async throws -> [CompletionCandidate] { + guard request.maxCompletionTokens > 0, request.maxDisplayWidth > 0 else { return [] } + // The system API does not expose token-level confidence. KeyType's mid-line safety gate + // therefore cannot evaluate these responses honestly, so suppress before spending latency. + guard request.context.afterCursor.isEmpty else { return [] } + try Task.checkCancellation() + let raw = try await respond( + Self.prompt(for: request), + Self.instructions(for: request), + request.maxCompletionTokens + Self.boundaryMarkerTokenAllowance + ) + // A superseded keystroke must never paint a late system-model response. + try Task.checkCancellation() + guard let candidate = Self.candidate(from: raw, request: request) else { return [] } + return [candidate] + } + + nonisolated static func instructions(for request: CompletionRequest) -> String { + [ + "Act as an inline autocomplete engine. Output one short continuation after .", + "The output must begin with exactly one boundary marker:", + "S: means insert one space before the remaining output.", + "J: means join the remaining output directly to the preceding text.", + "Return the marker followed immediately by only new characters. Never repeat input text.", + "Use J: when completing an unfinished word, identifier, or command fragment at the cursor.", + "Examples: \"Please send\" becomes \"S:the file\". \"See you tom\" becomes \"J:orrow\".", + "\"France is\" becomes \"S:Paris\". \"let pri\" becomes \"J:nt\".", + "\"git che\" becomes \"J:ckout\".", + "The input mode is \(modeName(request.mode)).", + "Return no explanation, label, quotation marks, or Markdown.", + "Keep the continuation within \(request.maxDisplayWidth) characters and approximately \(request.maxCompletionTokens) tokens." + ] + .joined(separator: "\n") + } + + nonisolated private static func modeName(_ mode: CompletionMode) -> String { + switch mode { + case .prose: return "prose" + case .code: return "code" + case .terminal: return "terminal" + case .emoji: return "emoji" + case .correction: return "correction" + } + } + + /// The existing prompt retains app metadata, personalization, and custom writing instructions. + /// Repeat only the live tail at the end so the system model has an unambiguous final cursor and + /// does not confuse KeyType's structured context with text that should be emitted. + nonisolated static func prompt(for request: CompletionRequest) -> String { + let liveTail = String(request.context.beforeCursor.suffix(512)) + let prompt = """ + \(request.prompt) + + ## Exact insertion target + The final below is the insertion point. Text before it is existing text and must not be repeated. + \(liveTail) + """ + return suffixOfPrompt(prompt, withinUTF8ByteBudget: maxPromptUTF8Bytes) + } + + nonisolated private static func suffixOfPrompt( + _ prompt: String, + withinUTF8ByteBudget budget: Int + ) -> String { + guard prompt.utf8.count > budget else { return prompt } + var start = prompt.endIndex + var remaining = budget + while start > prompt.startIndex { + let previous = prompt.index(before: start) + let byteCount = prompt[previous.. CompletionCandidate? { + // Foundation Models commonly normalizes leading whitespace in free-form string responses. + // The explicit marker makes the insertion boundary unambiguous, after which it is removed. + let marked = rawResponse.trimmingCharacters(in: .newlines) + let insertsSpace: Bool + let payload: Substring + if marked.hasPrefix("S:") { + insertsSpace = true + payload = marked.dropFirst(2) + } else if marked.hasPrefix("J:") { + insertsSpace = false + payload = marked.dropFirst(2) + } else { + return nil + } + guard let first = payload.first, !first.isWhitespace else { return nil } + guard !payload.hasPrefix("```") && !payload.hasSuffix("```") else { return nil } + + var text = String(payload) + if insertsSpace, request.context.beforeCursor.last?.isWhitespace != true { + text.insert(" ", at: text.startIndex) + } + guard !text.isEmpty, text.count <= request.maxDisplayWidth else { return nil } + if !request.requiredPrefixBytes.isEmpty { + guard Array(text.utf8).starts(with: request.requiredPrefixBytes) else { return nil } + } + return CompletionCandidate(text: text, mode: request.mode) + } +} + +enum AppleIntelligenceCompletionEngineFactory { + nonisolated static func make() throws -> AppleIntelligenceCompletionEngine { + let availability = AppleIntelligenceModelSupport.availability + guard availability.isAvailable else { + throw AppleIntelligenceCompletionError.unavailable(availability) + } + +#if canImport(FoundationModels) + if #available(macOS 26.0, *) { + return makeAvailableEngine() + } +#endif + throw AppleIntelligenceCompletionError.unavailable(.frameworkUnavailable) + } + +#if canImport(FoundationModels) + @available(macOS 26.0, *) + nonisolated private static func makeAvailableEngine() -> AppleIntelligenceCompletionEngine { + let model = SystemLanguageModel.default + return AppleIntelligenceCompletionEngine { prompt, instructions, maximumResponseTokens in + // Sessions retain a transcript and reject overlapping calls. A fresh one-shot session + // prevents cross-keystroke context leakage and lets rapid replacement tasks run safely. + let session = LanguageModelSession(model: model, instructions: instructions) + let options = GenerationOptions( + sampling: .greedy, + maximumResponseTokens: maximumResponseTokens + ) + let response = try await session.respond(to: prompt, options: options) + return response.content + } + } +#endif +} diff --git a/KeyType/Logic/Completion/CompletionController.swift b/KeyType/Logic/Completion/CompletionController.swift index 0a6cadc..460ad13 100644 --- a/KeyType/Logic/Completion/CompletionController.swift +++ b/KeyType/Logic/Completion/CompletionController.swift @@ -238,7 +238,8 @@ final class CompletionController { private let fullPromptLog = FullPromptLog() private let log = Logger(subsystem: "com.pattonium.KeyType", category: "completion") - private var engine: ConstrainedGenerationEngine? + private var engine: (any CompletionEngine)? + private var engineLoadTask: Task? private var generationTask: Task? private var debounceTask: Task? private var warmupTask: Task? @@ -262,12 +263,12 @@ final class CompletionController { private(set) var loadState: LoadState = .idle private(set) var isRunning = false - /// The model filename the current engine was (or is being) built from. Used to coalesce + /// The model selection the current engine was (or is being) built from. Used to coalesce /// redundant `reloadModel()` calls — e.g. when a freshly downloaded model is auto-selected, /// `onModelReady` reloads explicitly *and* the Settings picker's `onChange` fires for the same /// programmatic selection. Set synchronously on the main actor before each load suspends, so the /// second call sees the target already active and no-ops. - private var activeModelFilename: String? + private var activeModelSelection: CompletionModelSelection? /// The completion currently shown as ghost text (the portion still ahead of the live caret). /// Nil when nothing is displayed. Exposed for the Tab acceptance controller / UI binding. @@ -392,20 +393,29 @@ final class CompletionController { // Tune the decoder from accumulated local telemetry (bounded nudges) and honor the chosen // model. Both are read now, on the main actor, before suspending into the off-main load. let adjustments = ThresholdTuner.adjustments(for: telemetry.snapshot()) - let modelFilename = settings.selectedModelFilename ?? ModelContainer.defaultModelFilename - activeModelFilename = modelFilename - Task { + let modelSelection = settings.selectedCompletionModel + activeModelSelection = modelSelection + engineLoadTask?.cancel() + engineLoadTask = Task { [weak self] in + guard let self else { return } do { let engine = try await Self.buildEngine( - compatibilityStore: compatibilityStore, - modelFilename: modelFilename, + compatibilityStore: self.compatibilityStore, + modelSelection: modelSelection, adjustments: adjustments ) + guard !Task.isCancelled, self.activeModelSelection == modelSelection else { + await engine.shutdown() + return + } self.engine = engine + self.engineLoadTask = nil self.loadState = .ready self.startStartupWarmup(engine: engine) self.log.info("Completion engine ready") } catch { + guard !Task.isCancelled, self.activeModelSelection == modelSelection else { return } + self.engineLoadTask = nil self.loadState = .unavailable("\(error)") self.log.error("Completion engine unavailable: \(error, privacy: .public)") } @@ -418,9 +428,11 @@ final class CompletionController { /// (the post-download auto-select reloads *and* fires the picker's `onChange`) don't kick off two /// concurrent engine builds. func reloadModel() { - let target = settings.selectedModelFilename ?? ModelContainer.defaultModelFilename - guard target != activeModelFilename || loadState == .idle else { return } - activeModelFilename = target + let target = settings.selectedCompletionModel + guard target != activeModelSelection || loadState == .idle else { return } + activeModelSelection = target + engineLoadTask?.cancel() + engineLoadTask = nil reset() lastGenerationLatencyMs = nil Task { @@ -461,11 +473,17 @@ final class CompletionController { func shutdown() async { stop() warmupTask?.cancel() + let loadTask = engineLoadTask + loadTask?.cancel() + engineLoadTask = nil overlayCalibrationTask?.cancel() overlayCalibrationTask = nil lastGenerationLatencyMs = nil loadState = .idle - activeModelFilename = nil + activeModelSelection = nil + // Model construction can be non-cancellable inside llama.cpp. Wait for the stale-load guard + // to receive the completed engine and shut it down before allowing app termination. + await loadTask?.value if let engine { await engine.shutdown() } @@ -595,13 +613,15 @@ final class CompletionController { // the natural whole-word token (" great") instead of being stuck in a subword state where a // worse word (" greasy") outranks it. The re-emitted stem is stripped before display in // `present`. When there is no heal the request is the plain whole-prefix continuation. - let heal = MidWordHealing.plan(for: context) + let heal = engine.supportsTokenHealing ? MidWordHealing.plan(for: context) : nil let promptContext = heal.map { context.replacingBeforeCursor($0.head) } ?? context // Personalization, clipboard, and screen/OCR are all opt-in. Once a typing burst starts, the // optional side sections are frozen briefly so unrelated history/clipboard/OCR updates do // not rewrite the prompt prefix and destroy KV append reuse mid-burst. let (sideContext, sideContextReused) = promptSideContext(for: promptContext) - let promptResult = KeyTypeModuleGraph.makePromptBuilder().buildPrompt( + let promptResult = KeyTypeModuleGraph.makePromptBuilder( + maxPromptTokens: engine.maxPromptTokens + ).buildPrompt( context: promptContext, customInstructions: settings.promptCustomInstructions(appInstructions: policy.customInstructions), previousUserInputs: sideContext.previousUserInputs, @@ -1093,7 +1113,7 @@ final class CompletionController { ].joined(separator: "\u{1E}") } - private func startStartupWarmup(engine: ConstrainedGenerationEngine) { + private func startStartupWarmup(engine: any CompletionEngine) { warmupTask?.cancel() let context = TextFieldContext( beforeCursor: "The", @@ -1111,11 +1131,11 @@ final class CompletionController { startWarmup(engine: engine, request: request) } - private func startAnchorWarmup(engine: ConstrainedGenerationEngine, request: CompletionRequest) { + private func startAnchorWarmup(engine: any CompletionEngine, request: CompletionRequest) { startWarmup(engine: engine, request: request) } - private func startWarmup(engine: ConstrainedGenerationEngine, request: CompletionRequest) { + private func startWarmup(engine: any CompletionEngine, request: CompletionRequest) { warmupTask?.cancel() warmupTask = Task { [weak self] in do { @@ -1943,9 +1963,14 @@ final class CompletionController { /// isolated by default — so every step stays off main. nonisolated private static func buildEngine( compatibilityStore: AppCompatibilityStore, - modelFilename: String, + modelSelection: CompletionModelSelection, adjustments: ThresholdAdjustments - ) async throws -> ConstrainedGenerationEngine { + ) async throws -> any CompletionEngine { + if case .appleIntelligence = modelSelection { + return try AppleIntelligenceCompletionEngineFactory.make() + } + + let modelFilename = modelSelection.localFilename ?? ModelContainer.defaultModelFilename let modelURL = try ModelContainer.modelURL(filename: modelFilename) guard ModelContainer.modelExists(at: modelURL) else { throw CompletionLoadError.modelMissing(modelFilename) diff --git a/KeyType/Logic/Models/ModelSetupCoordinator.swift b/KeyType/Logic/Models/ModelSetupCoordinator.swift new file mode 100644 index 0000000..547c4e6 --- /dev/null +++ b/KeyType/Logic/Models/ModelSetupCoordinator.swift @@ -0,0 +1,291 @@ +// +// ModelSetupCoordinator.swift +// KeyType +// +// App-level orchestration that turns "download a model" into a complete, usable setup: it owns the +// `ModelDownloadManager` (GGUF fetch) and chains `ProfileGenerator` (ACPF build) after a GGUF lands, +// exposing a single combined per-model state the onboarding wizard and Settings can render. A model +// is only `.ready` once both its GGUF and its ACPF profile exist on disk. +// + +import Foundation +import LlamaModelRuntime +import ModelManagement +import ModelProfileGeneration +import ModelRuntime +import Observation +import os + +@MainActor +@Observable +final class ModelSetupCoordinator { + + /// Combined download + profile-generation state for one model. + enum SetupState: Equatable { + case idle + case downloading(progress: Double?) + case paused(progress: Double?) + case preparingProfile + case ready + case failed(String) + + var isBusy: Bool { + switch self { + case .downloading, .preparingProfile: return true + case .idle, .paused, .ready, .failed: return false + } + } + } + + let downloads: ModelDownloadManager + + /// Progress of an in-flight import of a user-supplied GGUF from outside the curated catalog. + /// Failures are not part of this state — they are surfaced via `onImportFailure` so the app can + /// present a modal alert the user must dismiss (see ADR-036), rather than an inline status line. + enum ImportState: Equatable { + case idle + case preparing(filename: String) + } + + /// Profile-generation phase per filename. Absent means no profile work is in flight. + private enum ProfilePhase: Equatable { + case preparing + case ready + case failed(String) + } + private var profilePhases: [String: ProfilePhase] = [:] + private var profileTasks: [String: Task] = [:] + + /// Live state of a user-initiated "Import a GGUF…" action. Observable, so Settings can show + /// progress and surface failures. Returns to `.idle` once the import is fully prepared. + private(set) var importState: ImportState = .idle + private var importTask: Task? + + /// Called on the main actor when a model becomes fully usable (GGUF + ACPF present). + var onModelReady: ((String) -> Void)? + + /// Called on the main actor with a user-facing message when an import fails (incompatible GGUF, + /// copy/profile error, …). The app wires this to a modal `NSAlert` the user must dismiss. + var onImportFailure: ((String) -> Void)? + + private let log = Logger(subsystem: "com.pattonium.KeyType", category: "model-setup") + + init(downloads: ModelDownloadManager? = nil) { + // The default is computed in-body (not as a default argument) because the manager's + // initializer is main-actor isolated and default-argument expressions are not. + let downloads = downloads ?? ModelDownloadManager() + self.downloads = downloads + self.downloads.onGGUFInstalled = { [weak self] model in + self?.startProfileGeneration(forFilename: model.filename) + } + } + + var catalog: [DownloadableRuntimeModel] { downloads.catalog } + + // MARK: - State + + /// The merged setup state for `model`, derived from the live download state plus any + /// profile-generation phase. Observable, so SwiftUI refreshes as either input changes. + func state(for model: DownloadableRuntimeModel) -> SetupState { + if let phase = profilePhases[model.filename] { + switch phase { + case .preparing: return .preparingProfile + case .ready: return .ready + case .failed(let message): return .failed(message) + } + } + switch downloads.state(for: model) { + case .idle: + return isFullyInstalled(model) ? .ready : .idle + case .downloading(let progress): + return .downloading(progress: progress) + case .paused(let progress): + return .paused(progress: progress) + case .downloaded: + return isFullyInstalled(model) ? .ready : .idle + case .failed(let message): + return .failed(message) + } + } + + /// A model is usable only when both its GGUF and its ACPF profile are on disk. + func isFullyInstalled(_ model: DownloadableRuntimeModel) -> Bool { + downloads.isInstalled(filename: model.filename) && isProfilePresent(for: model) + } + + private func isProfilePresent(for model: DownloadableRuntimeModel) -> Bool { + guard let url = try? ModelContainer.profileURL(family: model.tokenizerFamily) else { return false } + return FileManager.default.fileExists(atPath: url.path) + } + + // MARK: - Actions + + /// Begins (or resumes) setup for `model`: downloads the GGUF if missing, otherwise builds the + /// missing ACPF profile, otherwise no-ops if already ready. Selecting a model is the user's + /// explicit consent for any multi-gigabyte download. + func beginSetup(for model: DownloadableRuntimeModel) { + if isFullyInstalled(model) { + profilePhases[model.filename] = .ready + onModelReady?(model.filename) + return + } + if downloads.isInstalled(filename: model.filename) { + startProfileGeneration(forFilename: model.filename) + } else { + downloads.download(model) + } + } + + /// Pause an in-flight GGUF download, keeping resume data so it can continue later. + func pause(_ model: DownloadableRuntimeModel) { + downloads.pause(filename: model.filename) + } + + /// Resume a previously paused GGUF download. + func resume(_ model: DownloadableRuntimeModel) { + downloads.resume(model) + } + + func cancel(_ model: DownloadableRuntimeModel) { + downloads.cancel(filename: model.filename) + profileTasks[model.filename]?.cancel() + profileTasks[model.filename] = nil + if case .preparing = profilePhases[model.filename] { + profilePhases[model.filename] = nil + } + downloads.refreshStates() + } + + /// Import a GGUF the user already has on disk (downloaded elsewhere, outside our catalog): copy + /// it into the Models directory, build its ACPF profile from the GGUF's own tokenizer, then — + /// via `onModelReady` — select it and reload the engine. The model is only made the active one + /// once both files are present, mirroring the catalog setup path. Off-catalog models are + /// unvetted, so callers warn the user that behavior may be unexpected. + func importModel(from sourceURL: URL) { + let filename = sourceURL.lastPathComponent + guard filename.lowercased().hasSuffix(".gguf") else { + reportImportFailure("Choose a model file with a .gguf extension.", filename: filename, detail: nil) + return + } + importTask?.cancel() + importState = .preparing(filename: filename) + importTask = Task { [weak self] in + do { + // Compatibility gate first, straight from the chosen file: if this build of + // llama.cpp can't load the GGUF (unsupported architecture or a newer GGUF format), + // reject it with a clear warning before copying anything into the Models directory. + try await Self.assertModelCompatible(at: sourceURL, filename: filename) + let destination = try ModelContainer.modelURL(filename: filename) + try Self.copyIntoModelsDirectory(from: sourceURL, to: destination) + try await ProfileGenerator.generateProfileIfNeeded(forModelFilename: filename) + guard let self, !Task.isCancelled else { return } + self.importState = .idle + self.importTask = nil + self.downloads.refreshStates() + self.log.info("Imported GGUF \(filename, privacy: .public) ready (GGUF + ACPF present)") + self.onModelReady?(filename) + } catch { + guard let self, !Task.isCancelled else { return } + self.importTask = nil + self.reportImportFailure(Self.message(for: error), filename: filename, detail: String(describing: error)) + } + } + } + + /// Clear any in-flight state, log, and hand the user-facing message to `onImportFailure` (which + /// the app shows as a modal alert). `detail` is logged for diagnostics; `message` is shown. + private func reportImportFailure(_ message: String, filename: String, detail: String?) { + importState = .idle + log.error("Import failed for \(filename, privacy: .public): \(detail ?? message, privacy: .public)") + onImportFailure?(message) + } + + /// Probe whether the vendored llama.cpp build can actually bring up `url`. Loading is the + /// authoritative compatibility check: `llama_model_load_from_file` returns NULL (surfaced as + /// `LlamaRuntimeError.modelLoadFailed`) for a GGUF whose architecture or format version this + /// build doesn't support. A tiny context keeps the probe cheap, and we free it immediately — + /// ggml-metal asserts at process exit if a context's GPU residency was never released (ADR-021). + /// Throws `IncompatibleModelError` when the model is unusable so the caller can warn and abort. + private static func assertModelCompatible(at url: URL, filename: String) async throws { + do { + let probe = try LlamaModelRuntime(modelURL: url, contextLength: 256, reuseThreshold: 0) + await probe.shutdown() + } catch let error as LlamaRuntimeError where Self.indicatesIncompatibility(error) { + throw IncompatibleModelError(filename: filename, underlying: error) + } + } + + /// llama.cpp failure modes that mean "this build can't use this GGUF at all" (as opposed to a + /// transient/runtime decode error): the model, its context, or its vocab never came up. + private static func indicatesIncompatibility(_ error: LlamaRuntimeError) -> Bool { + switch error { + case .modelLoadFailed, .contextInitFailed, .vocabUnavailable: + return true + default: + return false + } + } + + /// User-facing message for an import failure. `CustomStringConvertible` errors (our typed llama / + /// profile errors) carry a readable description; `localizedDescription` would flatten them to a + /// generic "operation couldn't be completed" string, so prefer the description. + private static func message(for error: Error) -> String { + if let incompatible = error as? IncompatibleModelError { return incompatible.description } + if let convertible = error as? CustomStringConvertible, !(error is LocalizedError) { + return convertible.description + } + return error.localizedDescription + } + + /// Raised when a user-imported GGUF can't be loaded by the current llama.cpp build. + struct IncompatibleModelError: Error, CustomStringConvertible { + let filename: String + let underlying: LlamaRuntimeError + var description: String { + "“\(filename)” isn’t compatible with this version of KeyType’s model runtime (llama.cpp) " + + "and can’t be used. It may use an unsupported architecture or a newer GGUF format than " + + "this build supports." + } + } + + /// Copy `source` to its destination inside the Models directory, creating the directory and + /// replacing any existing file with the same name (re-importing refreshes the on-disk copy). + private static func copyIntoModelsDirectory(from source: URL, to destination: URL) throws { + let fm = FileManager.default + _ = try ModelContainer.modelsDirectoryURL(create: true) + if source.standardizedFileURL == destination.standardizedFileURL { return } + if fm.fileExists(atPath: destination.path) { + try fm.removeItem(at: destination) + } + try fm.copyItem(at: source, to: destination) + } + + func refresh() { + downloads.refreshStates() + // Drop stale "ready" phases for files that were deleted out from under us. + for model in catalog where profilePhases[model.filename] == .ready && !isFullyInstalled(model) { + profilePhases[model.filename] = nil + } + } + + private func startProfileGeneration(forFilename filename: String) { + guard profileTasks[filename] == nil else { return } + profilePhases[filename] = .preparing + let task = Task { [weak self] in + do { + try await ProfileGenerator.generateProfileIfNeeded(forModelFilename: filename) + guard let self else { return } + self.profilePhases[filename] = .ready + self.profileTasks[filename] = nil + self.log.info("Model \(filename, privacy: .public) ready (GGUF + ACPF present)") + self.onModelReady?(filename) + } catch { + guard let self else { return } + self.profilePhases[filename] = .failed(error.localizedDescription) + self.profileTasks[filename] = nil + self.log.error("Profile generation failed for \(filename, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + profileTasks[filename] = task + } +} diff --git a/KeyType/Logic/Settings/SettingsStore.swift b/KeyType/Logic/Settings/SettingsStore.swift index b1c6be4..90f7355 100644 --- a/KeyType/Logic/Settings/SettingsStore.swift +++ b/KeyType/Logic/Settings/SettingsStore.swift @@ -13,6 +13,33 @@ import AutocompleteCore import Foundation import Observation +/// The completion engine selected by the user. Local selections retain their GGUF filename while +/// the Apple option is backed by the system Foundation Model exposed by Apple Intelligence. +enum CompletionModelSelection: Hashable, Sendable { + case local(filename: String?) + case appleIntelligence + + nonisolated var localFilename: String? { + guard case .local(let filename) = self else { return nil } + return filename + } + + /// Stable value for engine reload coalescing and diagnostics. + nonisolated var identifier: String { + switch self { + case .local(let filename): + return "local:\(filename ?? "default")" + case .appleIntelligence: + return "apple-intelligence" + } + } +} + +enum CompletionModelProvider: String, Sendable { + case local + case appleIntelligence +} + /// Completion length presets, mapped to the decoder's token/width budget. enum CompletionLength: String, CaseIterable, Identifiable { case short @@ -110,6 +137,7 @@ final class SettingsStore { static let fullPromptLoggingEnabled = "KeyType.settings.fullPromptLoggingEnabled" static let developerOverrideTuningEnabled = "KeyType.settings.developerOverrideTuningEnabled" static let completionLength = "KeyType.settings.completionLength" + static let completionModelProvider = "KeyType.settings.completionModelProvider" static let selectedModelFilename = "KeyType.settings.selectedModelFilename" static let perAppDisabled = "KeyType.settings.perAppDisabledBundleIDs" static let manualPerAppDisplayNames = "KeyType.settings.manualPerAppDisplayNames" @@ -163,11 +191,39 @@ final class SettingsStore { didSet { defaults.set(completionLength.rawValue, forKey: Key.completionLength) } } + /// Which high-level engine supplies completions. Existing installs default to the local GGUF + /// path, preserving their current behavior and selected filename. + var completionModelProvider: CompletionModelProvider { + didSet { defaults.set(completionModelProvider.rawValue, forKey: Key.completionModelProvider) } + } + /// Chosen GGUF filename in the Models directory, or `nil` to use the app default. var selectedModelFilename: String? { didSet { defaults.set(selectedModelFilename, forKey: Key.selectedModelFilename) } } + /// One picker-friendly value spanning the system Apple model and all local GGUF choices. + var selectedCompletionModel: CompletionModelSelection { + get { + switch completionModelProvider { + case .local: + return .local(filename: selectedModelFilename) + case .appleIntelligence: + return .appleIntelligence + } + } + set { + switch newValue { + case .local(let filename): + selectedModelFilename = filename + completionModelProvider = .local + case .appleIntelligence: + // Keep the last local filename so switching back restores the user's prior choice. + completionModelProvider = .appleIntelligence + } + } + } + /// Bundle identifiers the user has turned completions off for. var perAppDisabled: Set { didSet { defaults.set(Array(perAppDisabled).sorted(), forKey: Key.perAppDisabled) } @@ -214,6 +270,8 @@ final class SettingsStore { self.developerOverrideTuningEnabled = defaults.bool(forKey: Key.developerOverrideTuningEnabled) self.completionLength = (defaults.string(forKey: Key.completionLength)) .flatMap(CompletionLength.init(rawValue:)) ?? .medium + self.completionModelProvider = defaults.string(forKey: Key.completionModelProvider) + .flatMap(CompletionModelProvider.init(rawValue:)) ?? .local self.selectedModelFilename = defaults.string(forKey: Key.selectedModelFilename) self.perAppDisabled = Set(defaults.stringArray(forKey: Key.perAppDisabled) ?? []) self.manualPerAppDisplayNames = diff --git a/KeyType/Logic/Telemetry/LatencyExportContext.swift b/KeyType/Logic/Telemetry/LatencyExportContext.swift index 5d73ed1..55f11c2 100644 --- a/KeyType/Logic/Telemetry/LatencyExportContext.swift +++ b/KeyType/Logic/Telemetry/LatencyExportContext.swift @@ -48,8 +48,16 @@ enum LatencyExportContext { } private static func currentEngineInfo(settings: SettingsStore) -> LatencyExportEngineInfo { - LatencyExportEngineInfo( - modelFilename: settings.selectedModelFilename, + let modelFilename: String? + switch settings.selectedCompletionModel { + case .local(let filename): + modelFilename = filename + case .appleIntelligence: + modelFilename = nil + } + return LatencyExportEngineInfo( + provider: settings.completionModelProvider.rawValue, + modelFilename: modelFilename, completionLengthLabel: settings.completionLength.rawValue ) } diff --git a/KeyType/Views/Settings/Sections/ModelSettingsView.swift b/KeyType/Views/Settings/Sections/ModelSettingsView.swift index e6deaee..fa0e279 100644 --- a/KeyType/Views/Settings/Sections/ModelSettingsView.swift +++ b/KeyType/Views/Settings/Sections/ModelSettingsView.swift @@ -26,15 +26,38 @@ struct ModelSettingsView: View { var body: some View { Form { Section("Model") { - Picker("Completion model", selection: $settings.selectedModelFilename) { - Text("Default (\(ModelContainer.defaultModelFilename))").tag(String?.none) + Picker("Completion model", selection: $settings.selectedCompletionModel) { + Text("Siri AI (Apple Intelligence)") + .tag(CompletionModelSelection.appleIntelligence) + .disabled(!appleIntelligenceAvailability.isAvailable) + Text("Default local (\(ModelContainer.defaultModelFilename))") + .tag(CompletionModelSelection.local(filename: nil)) ForEach(availableModels, id: \.self) { name in - Text(name).tag(String?.some(name)) + Text(name).tag(CompletionModelSelection.local(filename: name)) } } // Honor the "takes effect immediately" promise: a new selection flushes the resident - // model + KV cache and reloads from the chosen GGUF without a relaunch (see ADR-021). - .onChange(of: settings.selectedModelFilename) { reloadModel() } + // engine and reloads the chosen local or system model without a relaunch. + .onChange(of: settings.selectedCompletionModel) { reloadModel() } + + if settings.selectedCompletionModel == .appleIntelligence + || !appleIntelligenceAvailability.isAvailable { + Label( + appleIntelligenceAvailability.message, + systemImage: appleIntelligenceAvailability.isAvailable + ? "checkmark.circle.fill" + : "exclamationmark.triangle.fill" + ) + .font(.footnote) + .foregroundStyle(appleIntelligenceAvailability.isAvailable ? Color.green : Color.orange) + } + + if settings.selectedCompletionModel == .appleIntelligence { + Text("Uses Apple's on-device Foundation Model, the supported model behind Apple Intelligence. Apps cannot call Siri's assistant or personal context directly.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } Section("Available models") { @@ -93,6 +116,10 @@ struct ModelSettingsView: View { return false } + private var appleIntelligenceAvailability: AppleIntelligenceModelAvailability { + AppleIntelligenceModelSupport.availability + } + @ViewBuilder private var importStatusLine: some View { switch modelSetup.importState { diff --git a/KeyType/Views/Setup/OnboardingView.swift b/KeyType/Views/Setup/OnboardingView.swift index 91efbdf..7dcaa5d 100644 --- a/KeyType/Views/Setup/OnboardingView.swift +++ b/KeyType/Views/Setup/OnboardingView.swift @@ -25,7 +25,9 @@ struct OnboardingView: View { @Environment(\.dismissWindow) private var dismissWindow @State private var step: Step = .welcome - @State private var selectedModelFilename: String = ModelContainer.defaultModelFilename + @State private var selectedModel: CompletionModelSelection = .local( + filename: ModelContainer.defaultModelFilename + ) var body: some View { VStack(spacing: 0) { @@ -144,15 +146,24 @@ struct OnboardingView: View { } private func finish() { + if selectedModelIsReady { + settings.selectedCompletionModel = selectedModel + } markCompleted() dismissWindow(id: AppDelegate.onboardingWindowID) } private var selectedModelIsReady: Bool { - guard let model = modelSetup.catalog.first(where: { $0.filename == selectedModelFilename }) else { - return false + switch selectedModel { + case .appleIntelligence: + return AppleIntelligenceModelSupport.availability.isAvailable + case .local(let filename): + guard let filename, + let model = modelSetup.catalog.first(where: { $0.filename == filename }) else { + return false + } + return modelSetup.isFullyInstalled(model) } - return modelSetup.isFullyInstalled(model) } // MARK: - Steps @@ -165,7 +176,7 @@ struct OnboardingView: View { .padding(.top, 8) Text("Welcome to KeyType") .font(.title.weight(.semibold)) - Text("On-device tab-autocomplete for any text field on your Mac. Private by default, powered by a local model.") + Text("On-device tab-autocomplete for any text field on your Mac. Private by default, powered by the model you choose.") .font(.callout) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -210,16 +221,21 @@ struct OnboardingView: View { private var modelStep: some View { StepHeader( title: "Choose a model", - subtitle: "Everything runs locally on your Mac. Pick one to download, or use one you've already added." + subtitle: "Everything runs locally on your Mac. Use Apple Intelligence or pick a downloadable model." ) VStack(spacing: 10) { + AppleIntelligenceModelCard( + availability: AppleIntelligenceModelSupport.availability, + isSelected: selectedModel == .appleIntelligence, + onSelect: { selectedModel = .appleIntelligence } + ) ForEach(modelSetup.catalog) { model in ModelCard( model: model, state: modelSetup.state(for: model), - isSelected: selectedModelFilename == model.filename, + isSelected: selectedModel == .local(filename: model.filename), onSelect: { - selectedModelFilename = model.filename + selectedModel = .local(filename: model.filename) modelSetup.beginSetup(for: model) }, onCancel: { modelSetup.cancel(model) }, @@ -524,6 +540,55 @@ private struct PermissionCard: View { } } +private struct AppleIntelligenceModelCard: View { + let availability: AppleIntelligenceModelAvailability + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: "apple.intelligence") + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + VStack(alignment: .leading, spacing: 2) { + Text("Siri AI (Apple Intelligence)").font(.headline) + Text("Apple's built-in on-device Foundation Model. No model download or API key required by KeyType.") + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 8) + if isSelected, availability.isAvailable { + Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) + } + } + Label( + availability.message, + systemImage: availability.isAvailable + ? "checkmark.circle.fill" + : "exclamationmark.triangle.fill" + ) + .font(.footnote) + .foregroundStyle(availability.isAvailable ? Color.green : Color.orange) + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.08) : Color(nsColor: .controlBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(isSelected ? Color.accentColor : Color.secondary.opacity(0.15), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(!availability.isAvailable) + } +} + private struct ModelCard: View { let model: DownloadableRuntimeModel let state: ModelSetupCoordinator.SetupState diff --git a/KeyTypeTests/KeyTypeTests.swift b/KeyTypeTests/KeyTypeTests.swift index 3a3be53..8efa741 100644 --- a/KeyTypeTests/KeyTypeTests.swift +++ b/KeyTypeTests/KeyTypeTests.swift @@ -8,6 +8,7 @@ import AutocompleteCore import AppKit import CompletionUI +import ConstrainedGeneration import MacContextCapture import Testing @testable import KeyType @@ -15,6 +16,32 @@ import Testing struct KeyTypeTests { private static let target = AppTarget(bundleIdentifier: "com.test.app", appName: "Test") + private actor AppleResponderRecorder { + struct Call: Equatable, Sendable { + var prompt: String + var instructions: String + var maximumResponseTokens: Int + } + + var response: String + private(set) var calls: [Call] = [] + + init(response: String) { + self.response = response + } + + func respond(prompt: String, instructions: String, maximumResponseTokens: Int) -> String { + calls.append( + Call( + prompt: prompt, + instructions: instructions, + maximumResponseTokens: maximumResponseTokens + ) + ) + return response + } + } + private static func temporaryDefaults() -> (UserDefaults, String) { let suiteName = "KeyTypeTests.\(UUID().uuidString)" guard let defaults = UserDefaults(suiteName: suiteName) else { @@ -49,6 +76,27 @@ struct KeyTypeTests { TextFieldContext(beforeCursor: beforeCursor + typedSinceAnchor, target: target) } + private static func appleCompletionRequest( + prompt: String = "PROMPT-AT-CURSOR", + beforeCursor: String = "Please send", + afterCursor: String = "", + requiredPrefix: String = "", + maxCompletionTokens: Int = 8, + maxDisplayWidth: Int = 60 + ) -> CompletionRequest { + CompletionRequest( + context: TextFieldContext( + beforeCursor: beforeCursor, + afterCursor: afterCursor, + target: target + ), + prompt: prompt, + requiredPrefixBytes: Array(requiredPrefix.utf8), + maxCompletionTokens: maxCompletionTokens, + maxDisplayWidth: maxDisplayWidth + ) + } + @Test func adaptiveDebounceUsesFastPathAfterResponsiveGeneration() { #expect(CompletionController.adaptiveDebounceNanoseconds(lastGenerationLatencyMs: 35) == 15_000_000) } @@ -223,6 +271,191 @@ struct KeyTypeTests { #expect(!store.screenshotCalibrationEnabled) } + @Test @MainActor func completionModelDefaultsToLegacyLocalSelection() { + let (defaults, suiteName) = Self.temporaryDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = SettingsStore(defaults: defaults) + + #expect(store.selectedCompletionModel == .local(filename: nil)) + #expect(store.completionModelProvider == .local) + } + + @Test @MainActor func appleIntelligenceSelectionPersistsWithoutForgettingLocalModel() { + let (defaults, suiteName) = Self.temporaryDefaults() + defer { defaults.removePersistentDomain(forName: suiteName) } + + let store = SettingsStore(defaults: defaults) + store.selectedCompletionModel = .local(filename: "custom.gguf") + store.selectedCompletionModel = .appleIntelligence + + let reloaded = SettingsStore(defaults: defaults) + + #expect(reloaded.selectedCompletionModel == .appleIntelligence) + #expect(reloaded.selectedModelFilename == "custom.gguf") + reloaded.selectedCompletionModel = .local(filename: reloaded.selectedModelFilename) + #expect(reloaded.selectedCompletionModel == .local(filename: "custom.gguf")) + } + + @Test func appleIntelligenceEngineForwardsPromptBudgetAndReturnsContinuation() async throws { + let recorder = AppleResponderRecorder(response: "S:the update today") + let engine = AppleIntelligenceCompletionEngine { prompt, instructions, maximumTokens in + await recorder.respond( + prompt: prompt, + instructions: instructions, + maximumResponseTokens: maximumTokens + ) + } + let request = Self.appleCompletionRequest(maxCompletionTokens: 8) + + let candidates = try await engine.completions(for: request) + let calls = await recorder.calls + + #expect(candidates.map(\.text) == [" the update today"]) + #expect(calls.count == 1) + #expect(calls.first?.prompt.contains(request.prompt) == true) + #expect(calls.first?.prompt.contains("Please send") == true) + #expect(calls.first?.maximumResponseTokens == 12) + #expect(calls.first?.instructions.contains("boundary marker") == true) + #expect(engine.maxPromptTokens == 600) + #expect(!engine.supportsTokenHealing) + } + + @Test func appleIntelligenceBoundaryMarkersProduceExactInsertionText() { + let request = Self.appleCompletionRequest() + let afterWhitespace = Self.appleCompletionRequest(beforeCursor: "Please send ") + + #expect( + AppleIntelligenceCompletionEngine.candidate(from: "S:the file", request: request)?.text + == " the file" + ) + #expect( + AppleIntelligenceCompletionEngine.candidate(from: "J:orrow", request: request)?.text + == "orrow" + ) + #expect( + AppleIntelligenceCompletionEngine.candidate(from: "S:the file", request: afterWhitespace)?.text + == "the file" + ) + #expect(AppleIntelligenceCompletionEngine.candidate(from: "Send it", request: request) == nil) + #expect(AppleIntelligenceCompletionEngine.candidate(from: "Just ask", request: request) == nil) + } + + @Test func appleIntelligencePromptHasConservativeUTF8BudgetAndKeepsFinalCursor() { + let request = Self.appleCompletionRequest( + prompt: String(repeating: "界", count: 4_000), + beforeCursor: String(repeating: "🙂", count: 800) + ) + + let prompt = AppleIntelligenceCompletionEngine.prompt(for: request) + + #expect(prompt.utf8.count <= AppleIntelligenceCompletionEngine.maxPromptUTF8Bytes) + #expect(prompt.hasSuffix("")) + } + + @Test func appleIntelligenceEngineSuppressesMidLineWithoutCallingSystemModel() async throws { + let recorder = AppleResponderRecorder(response: " bridge") + let engine = AppleIntelligenceCompletionEngine { prompt, instructions, maximumTokens in + await recorder.respond( + prompt: prompt, + instructions: instructions, + maximumResponseTokens: maximumTokens + ) + } + let request = Self.appleCompletionRequest(afterCursor: " existing suffix") + + let candidates = try await engine.completions(for: request) + let calls = await recorder.calls + + #expect(candidates.isEmpty) + #expect(calls.isEmpty) + } + + @Test func appleIntelligenceEngineRejectsUnsafeOrUnconstrainedResponses() { + let requiredPrefix = "UNTRUSTED-PREFIX" + let request = Self.appleCompletionRequest(requiredPrefix: requiredPrefix, maxDisplayWidth: 24) + + #expect(AppleIntelligenceCompletionEngine.candidate(from: "wrong", request: request) == nil) + #expect(AppleIntelligenceCompletionEngine.candidate(from: "X:UNTRUSTED-PREFIX", request: request) == nil) + #expect(AppleIntelligenceCompletionEngine.candidate(from: "J: UNTRUSTED-PREFIX", request: request) == nil) + #expect(AppleIntelligenceCompletionEngine.candidate(from: "S:```UNTRUSTED-PREFIX```", request: request) == nil) + #expect(AppleIntelligenceCompletionEngine.candidate(from: "J:UNTRUSTED-PREFIX is too long", request: request) == nil) + #expect( + AppleIntelligenceCompletionEngine.candidate( + from: "\nJ:UNTRUSTED-PREFIX\n", + request: request + )?.text == requiredPrefix + ) + #expect(!AppleIntelligenceCompletionEngine.instructions(for: request).contains(requiredPrefix)) + } + + @Test func appleIntelligenceAvailabilityStatesHaveDeterministicMessages() { + let states: [AppleIntelligenceModelAvailability] = [ + .available, + .requiresMacOS26, + .frameworkUnavailable, + .deviceNotEligible, + .appleIntelligenceNotEnabled, + .modelNotReady, + .unsupportedLocale, + .unavailable + ] + + #expect(states.allSatisfy { !$0.message.isEmpty }) + #expect(states.filter { $0.isAvailable } == [.available]) + } + + @Test func appleIntelligenceCorrectionFallbackKeepsOnlySpellcheckCandidates() async throws { + let range = TextRangeDescriptor(container: .beforeCursor, startOffset: 0, endOffset: 4) + let spellcheck = CorrectionCandidate( + original: "teh", + replacement: "the", + originalRange: range, + confidence: 0.9, + source: .spellcheckOnly, + validation: .spellcheckOnly + ) + let grammar = CorrectionCandidate( + original: "are", + replacement: "is", + originalRange: range, + confidence: 0.9, + source: .systemGrammarOnly, + validation: .spellcheckOnly + ) + let engine = AppleIntelligenceCompletionEngine { _, _, _ in " continuation" } + + let validated = try await engine.validateCorrectionCandidates( + [grammar, spellcheck], + prefixBeforeWord: "", + suffixWindow: "", + priorPredictionReplacement: nil, + thresholds: CorrectionValidationThresholds() + ) + + #expect(validated.map(\.source) == [.spellcheckOnly]) + } + + @Test func appleIntelligenceEnginePropagatesCancellation() async { + let engine = AppleIntelligenceCompletionEngine { _, _, _ in + try await Task.sleep(nanoseconds: 5_000_000_000) + return " late response" + } + let task = Task { + try await engine.completions(for: Self.appleCompletionRequest()) + } + task.cancel() + + do { + _ = try await task.value + Issue.record("Expected cancellation") + } catch is CancellationError { + // Expected: a superseded keystroke cannot present a late response. + } catch { + Issue.record("Expected CancellationError, got \(error)") + } + } + @Test @MainActor func privacyDefaultsPreserveExplicitUserChoices() { let (defaults, suiteName) = Self.temporaryDefaults() defer { defaults.removePersistentDomain(forName: suiteName) } diff --git a/Packages/Personalization/Sources/Personalization/LatencyExport.swift b/Packages/Personalization/Sources/Personalization/LatencyExport.swift index 46b4ada..93e226f 100644 --- a/Packages/Personalization/Sources/Personalization/LatencyExport.swift +++ b/Packages/Personalization/Sources/Personalization/LatencyExport.swift @@ -6,8 +6,8 @@ import Foundation /// explicitly chooses to share it. See ADR-070. /// /// Privacy notes: -/// - Carries only non-text counters, the GGUF model filename, and timing samples. No captured user -/// text, no per-app identifiers, no clipboard or OCR content. +/// - Carries only non-text counters, the model provider, an optional GGUF filename, and timing +/// samples. No captured user text, per-app identifiers, clipboard, or OCR content. /// - Schema is versioned so an offline analyzer can route through the right decoder when fields are /// added, renamed, or removed in future builds. public struct LatencyExport: Codable, Equatable, Sendable { @@ -76,10 +76,16 @@ public struct LatencyExportDeviceInfo: Codable, Equatable, Sendable { /// Engine/decoder context behind a latency sample — different models and length presets produce /// very different `generation` percentiles, so analysing latency without this is misleading. public struct LatencyExportEngineInfo: Codable, Equatable, Sendable { + public var provider: String? public var modelFilename: String? public var completionLengthLabel: String? - public init(modelFilename: String? = nil, completionLengthLabel: String? = nil) { + public init( + provider: String? = nil, + modelFilename: String? = nil, + completionLengthLabel: String? = nil + ) { + self.provider = provider self.modelFilename = modelFilename self.completionLengthLabel = completionLengthLabel } diff --git a/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift b/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift index d787dba..08e80b8 100644 --- a/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift +++ b/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift @@ -363,6 +363,7 @@ final class PersonalizationTests: XCTestCase { appBuild: "456" ) let engine = LatencyExportEngineInfo( + provider: "local", modelFilename: "qwen3-1.7b-q4_K_M.gguf", completionLengthLabel: "medium" ) diff --git a/README.md b/README.md index a1c23af..fc1b724 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,15 @@ An open-source, on-device, system-wide tab-autocomplete utility for macOS. **KeyType** is an open-source, on-device, system-wide **tab-autocomplete utility for macOS**. It watches the focused text field across any app, predicts a short continuation at the cursor -using a **local LLM**, and offers it as ghost text that you accept with **Tab**. +using an **on-device model**, and offers it as ghost text that you accept with **Tab**. + +## Model options + +- **Local GGUF models** — KeyType's original llama.cpp path, available on macOS 14 and later. +- **Siri AI (Apple Intelligence)** — uses Apple's on-device Foundation Model through the public + Foundation Models framework on eligible Macs running macOS 26 or later. It needs Apple + Intelligence enabled and ready, but no API key. Apple does not expose Siri's assistant or its + personal context directly to third-party apps. It is a MIT-licensed alternative to the closed-source app *Cotypist*. @@ -38,7 +46,8 @@ It is a MIT-licensed alternative to the closed-source app *Cotypist*. ### Development -Requirements: macOS 14+ and a recent version of Xcode. +Requirements: macOS 14+ and a recent version of Xcode. Building the Apple Intelligence provider +requires the macOS 26 SDK or later. ```sh git clone https://github.com/johnbean393/KeyType.git @@ -78,4 +87,4 @@ KeyType/ ## License -MIT — see [LICENSE](https://github.com/johnbean393/KeyType/blob/main/LICENSE). \ No newline at end of file +MIT — see [LICENSE](https://github.com/johnbean393/KeyType/blob/main/LICENSE). diff --git a/docs/00-overview.md b/docs/00-overview.md index e27c194..78a5919 100644 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -10,7 +10,7 @@ KeyType is an **open-source, on-device, system-wide tab-autocomplete utility for macOS**. It watches the focused text field across any app, predicts a short continuation at the -cursor using a **local LLM**, and offers it as ghost text that the user accepts with **Tab**. +cursor using an **on-device model**, and offers it as ghost text that the user accepts with **Tab**. It is an **alternative** to the closed-source app *Cotypist* @@ -94,6 +94,8 @@ constrained decode → filter → overlay → Tab-insert all work against live m handling, insertion/overlay tuning, secure-field exclusion) (ADR-022+). - ✅ App target — background menu-bar / agent app with onboarding, in-app model download, Settings, encrypted local writing history, and local telemetry (ADR-005/023/034). +- ✅ Apple Intelligence provider — an optional macOS 26+ `FoundationModels` text-generation engine + behind the shared completion contract, with availability-aware Settings/onboarding UI (ADR-116). For the current set of open improvement themes (vs. the completed build milestones), see `04-roadmap.md`. diff --git a/docs/01-architecture.md b/docs/01-architecture.md index c6e0898..3a5f243 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -87,6 +87,13 @@ it is not used; see ADR-018). `StubModelRuntime` + `UTF8FallbackTokenizer` are r keep the `LocalModelRuntime` protocol stable (the `anchoredLogits` default extension keeps stubs unchanged). +The optional **Apple Intelligence** provider is wired separately in the app target behind +`CompletionGenerating` (ADR-116). Apple's `SystemLanguageModel` returns generated text rather than +token IDs, raw vocabulary bytes, or logits, so it does not implement `LocalModelRuntime` and does +not use ACPF/constrained beam decoding. It keeps the shared cancellation and output-filter stages, +uses a shorter prompt budget, and conservatively suppresses mid-line generation where provider +confidence is unavailable. + ### `ConstrainedGeneration` — the decoding loop ✅(multi-branch) `ConstrainedGenerationEngine: CompletionGenerating` performs real constrained decoding (M5, ADR-010): a deterministic best-first **multi-branch** search honouring `branchWidth`, diff --git a/docs/05-decisions.md b/docs/05-decisions.md index 017deda..0106b2c 100644 --- a/docs/05-decisions.md +++ b/docs/05-decisions.md @@ -135,6 +135,7 @@ row here.** | 113 | Route prefix-only spellcheck replacements to completion | correction/completion | | 114 | Lower spellcheck correction model margin | correction/model-runtime | | 115 | Remove aggressive correction mode | correction/settings | +| 116 | Add Apple Intelligence behind the completion-engine contract | model-runtime/settings | --- @@ -3589,3 +3590,28 @@ text. Both are now closed: detector and correction validation thresholds. - Consequences: Correction behavior is simpler to reason about and the Settings UI has one fewer safety-related toggle. Previously stored user defaults for the removed key are ignored. + +## ADR-116 — Add Apple Intelligence behind the completion-engine contract + +- Date: 2026-07-15 +- Status: accepted +- Context: Apple exposes the on-device model that powers Apple Intelligence through the macOS 26+ + `FoundationModels` framework. It does not expose Siri's assistant, personal context, tokenizer, + vocabulary bytes, next-token logits, or KV state. Treating the system model as a + `LocalModelRuntime` would therefore fabricate capabilities required by KeyType's constrained + decoder and correction scorer. +- Decision: Add an app-level `CompletionEngine` lifecycle contract above `CompletionGenerating`. + Keep `ConstrainedGenerationEngine` as the GGUF/logit implementation and add a one-shot + `LanguageModelSession` adapter for `SystemLanguageModel.default`. Persist provider choice + separately from the last local filename, keep macOS 14 deployment compatibility with availability + gates, use a conservative 600-token builder budget plus a 2,600-byte final prompt cap, disable + token healing and mid-line generation for the text-only provider, and retain only spellcheck-only + corrections when logit validation is absent. + The UI calls the requested option “Siri AI (Apple Intelligence)” but explains that it uses the + public Apple Intelligence Foundation Model rather than direct Siri access. +- Consequences: Eligible macOS 26+ users can choose Apple's on-device model without downloading a + GGUF or supplying an API key. Existing installs remain on their current local model by default, + switching back restores the previous filename, rapid requests use isolated sessions, and older or + ineligible systems show a specific availability reason. Apple-provider mid-line completions and + model-scored grammar corrections stay suppressed until Apple exposes trustworthy confidence or + token-scoring primitives. From 97d0ce68e90f5025df66be753761eb6787a2c28f Mon Sep 17 00:00:00 2001 From: boggedbrush <90526147+boggedbrush@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:02:21 -0400 Subject: [PATCH 2/2] Simplify Apple Intelligence integration --- .../AppleIntelligenceCompletionEngine.swift | 81 ++++++++----------- .../Completion/CompletionController.swift | 2 +- KeyType/Logic/Settings/SettingsStore.swift | 9 --- .../Telemetry/LatencyExportContext.swift | 12 +-- .../Personalization/LatencyExport.swift | 7 +- .../PersonalizationTests.swift | 1 - 6 files changed, 40 insertions(+), 72 deletions(-) diff --git a/KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift b/KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift index d010994..263a68f 100644 --- a/KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift +++ b/KeyType/Logic/Completion/AppleIntelligenceCompletionEngine.swift @@ -56,7 +56,7 @@ extension ConstrainedGenerationEngine: CompletionEngine { nonisolated var maxPromptTokens: Int { 4_096 } } -enum AppleIntelligenceModelAvailability: Equatable, Sendable { +enum AppleIntelligenceModelAvailability: Equatable, LocalizedError, Sendable { case available case requiresMacOS26 case frameworkUnavailable @@ -91,6 +91,10 @@ enum AppleIntelligenceModelAvailability: Equatable, Sendable { return "Apple Intelligence is currently unavailable." } } + + nonisolated var errorDescription: String? { + "Siri AI (Apple Intelligence) is unavailable. \(message)" + } } enum AppleIntelligenceModelSupport { @@ -123,17 +127,6 @@ enum AppleIntelligenceModelSupport { #endif } -enum AppleIntelligenceCompletionError: Error, LocalizedError { - case unavailable(AppleIntelligenceModelAvailability) - - nonisolated var errorDescription: String? { - switch self { - case .unavailable(let availability): - return "Siri AI (Apple Intelligence) is unavailable. \(availability.message)" - } - } -} - /// A framework-independent engine whose responder is injectable. Production injects a fresh /// `LanguageModelSession` per keystroke; tests inject a deterministic closure and need no real /// Apple Intelligence model, hardware eligibility, or network access. @@ -156,6 +149,36 @@ final class AppleIntelligenceCompletionEngine: CompletionEngine, @unchecked Send self.respond = respond } + nonisolated static func make() throws -> AppleIntelligenceCompletionEngine { + let availability = AppleIntelligenceModelSupport.availability + guard availability.isAvailable else { throw availability } + +#if canImport(FoundationModels) + if #available(macOS 26.0, *) { + return makeAvailableEngine() + } +#endif + throw AppleIntelligenceModelAvailability.frameworkUnavailable + } + +#if canImport(FoundationModels) + @available(macOS 26.0, *) + nonisolated private static func makeAvailableEngine() -> AppleIntelligenceCompletionEngine { + let model = SystemLanguageModel.default + return AppleIntelligenceCompletionEngine { prompt, instructions, maximumResponseTokens in + // Sessions retain a transcript and reject overlapping calls. A fresh one-shot session + // prevents cross-keystroke context leakage and lets rapid replacement tasks run safely. + let session = LanguageModelSession(model: model, instructions: instructions) + let options = GenerationOptions( + sampling: .greedy, + maximumResponseTokens: maximumResponseTokens + ) + let response = try await session.respond(to: prompt, options: options) + return response.content + } + } +#endif + nonisolated func completions(for request: CompletionRequest) async throws -> [CompletionCandidate] { guard request.maxCompletionTokens > 0, request.maxDisplayWidth > 0 else { return [] } // The system API does not expose token-level confidence. KeyType's mid-line safety gate @@ -265,37 +288,3 @@ final class AppleIntelligenceCompletionEngine: CompletionEngine, @unchecked Send return CompletionCandidate(text: text, mode: request.mode) } } - -enum AppleIntelligenceCompletionEngineFactory { - nonisolated static func make() throws -> AppleIntelligenceCompletionEngine { - let availability = AppleIntelligenceModelSupport.availability - guard availability.isAvailable else { - throw AppleIntelligenceCompletionError.unavailable(availability) - } - -#if canImport(FoundationModels) - if #available(macOS 26.0, *) { - return makeAvailableEngine() - } -#endif - throw AppleIntelligenceCompletionError.unavailable(.frameworkUnavailable) - } - -#if canImport(FoundationModels) - @available(macOS 26.0, *) - nonisolated private static func makeAvailableEngine() -> AppleIntelligenceCompletionEngine { - let model = SystemLanguageModel.default - return AppleIntelligenceCompletionEngine { prompt, instructions, maximumResponseTokens in - // Sessions retain a transcript and reject overlapping calls. A fresh one-shot session - // prevents cross-keystroke context leakage and lets rapid replacement tasks run safely. - let session = LanguageModelSession(model: model, instructions: instructions) - let options = GenerationOptions( - sampling: .greedy, - maximumResponseTokens: maximumResponseTokens - ) - let response = try await session.respond(to: prompt, options: options) - return response.content - } - } -#endif -} diff --git a/KeyType/Logic/Completion/CompletionController.swift b/KeyType/Logic/Completion/CompletionController.swift index 460ad13..53d4a0f 100644 --- a/KeyType/Logic/Completion/CompletionController.swift +++ b/KeyType/Logic/Completion/CompletionController.swift @@ -1967,7 +1967,7 @@ final class CompletionController { adjustments: ThresholdAdjustments ) async throws -> any CompletionEngine { if case .appleIntelligence = modelSelection { - return try AppleIntelligenceCompletionEngineFactory.make() + return try AppleIntelligenceCompletionEngine.make() } let modelFilename = modelSelection.localFilename ?? ModelContainer.defaultModelFilename diff --git a/KeyType/Logic/Settings/SettingsStore.swift b/KeyType/Logic/Settings/SettingsStore.swift index 90f7355..d0e31b5 100644 --- a/KeyType/Logic/Settings/SettingsStore.swift +++ b/KeyType/Logic/Settings/SettingsStore.swift @@ -24,15 +24,6 @@ enum CompletionModelSelection: Hashable, Sendable { return filename } - /// Stable value for engine reload coalescing and diagnostics. - nonisolated var identifier: String { - switch self { - case .local(let filename): - return "local:\(filename ?? "default")" - case .appleIntelligence: - return "apple-intelligence" - } - } } enum CompletionModelProvider: String, Sendable { diff --git a/KeyType/Logic/Telemetry/LatencyExportContext.swift b/KeyType/Logic/Telemetry/LatencyExportContext.swift index 55f11c2..fd730b4 100644 --- a/KeyType/Logic/Telemetry/LatencyExportContext.swift +++ b/KeyType/Logic/Telemetry/LatencyExportContext.swift @@ -48,16 +48,8 @@ enum LatencyExportContext { } private static func currentEngineInfo(settings: SettingsStore) -> LatencyExportEngineInfo { - let modelFilename: String? - switch settings.selectedCompletionModel { - case .local(let filename): - modelFilename = filename - case .appleIntelligence: - modelFilename = nil - } - return LatencyExportEngineInfo( - provider: settings.completionModelProvider.rawValue, - modelFilename: modelFilename, + LatencyExportEngineInfo( + modelFilename: settings.selectedCompletionModel.localFilename, completionLengthLabel: settings.completionLength.rawValue ) } diff --git a/Packages/Personalization/Sources/Personalization/LatencyExport.swift b/Packages/Personalization/Sources/Personalization/LatencyExport.swift index 93e226f..331ab9b 100644 --- a/Packages/Personalization/Sources/Personalization/LatencyExport.swift +++ b/Packages/Personalization/Sources/Personalization/LatencyExport.swift @@ -6,8 +6,8 @@ import Foundation /// explicitly chooses to share it. See ADR-070. /// /// Privacy notes: -/// - Carries only non-text counters, the model provider, an optional GGUF filename, and timing -/// samples. No captured user text, per-app identifiers, clipboard, or OCR content. +/// - Carries only non-text counters, the GGUF model filename, and timing samples. No captured user +/// text, per-app identifiers, clipboard, or OCR content. /// - Schema is versioned so an offline analyzer can route through the right decoder when fields are /// added, renamed, or removed in future builds. public struct LatencyExport: Codable, Equatable, Sendable { @@ -76,16 +76,13 @@ public struct LatencyExportDeviceInfo: Codable, Equatable, Sendable { /// Engine/decoder context behind a latency sample — different models and length presets produce /// very different `generation` percentiles, so analysing latency without this is misleading. public struct LatencyExportEngineInfo: Codable, Equatable, Sendable { - public var provider: String? public var modelFilename: String? public var completionLengthLabel: String? public init( - provider: String? = nil, modelFilename: String? = nil, completionLengthLabel: String? = nil ) { - self.provider = provider self.modelFilename = modelFilename self.completionLengthLabel = completionLengthLabel } diff --git a/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift b/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift index 08e80b8..d787dba 100644 --- a/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift +++ b/Packages/Personalization/Tests/PersonalizationTests/PersonalizationTests.swift @@ -363,7 +363,6 @@ final class PersonalizationTests: XCTestCase { appBuild: "456" ) let engine = LatencyExportEngineInfo( - provider: "local", modelFilename: "qwen3-1.7b-q4_K_M.gguf", completionLengthLabel: "medium" )