grammargem/Sources/GrammarGem/AI/MLXEngine.swift
BigMarc 201fe04d8a feat: backend-neutral AI seam, Ollama backend, extension bridge + trust/correctness fixes
Wave 1 (correctness/trust):
- Fix paid large-model dead path: engine reads the persisted selected repo via a
  thread-safe ModelManager.activeRepo(), so download/load/isReady now agree.
- Lemon Squeezy store/product check fails closed in release builds — the
  placeholder-ID 'any key unlocks paid' bypass can no longer ship.
- Replace force-unwrapped Hugging Face URLs with safe URLComponents construction.
- Never read/replace/scan secure (password) fields via AX.isSecureTextField.

Wave 2 (compounding seam):
- Extract AIPromptKit (prompts/temperature/maxTokens/cleanup) out of MLXEngine so
  every backend produces identical prompts and output.
- AIEngine gains protectedTerms (personal dictionary now actually injected into the
  LLM), warmup(), and runStreaming(); add makeAIEngine() factory.
- Honor WritingMode.lengthCap for maxTokens; warm the model on launch/after download.
- Guard custom modes against empty prompts and duplicate names.

Wave 3: OllamaEngine — a URLSession-only AIEngine backend (cross-platform, opt-in
via GRAMMARGEM_AI_BACKEND=ollama).

Wave 4: LocalEngineServer — loopback-only, token-authed HTTP bridge (Network.framework,
no new deps), off by default, exposing Harper + the LLM + the entitlement gate to a
browser extension.

Tests: 6 new unit tests for the AIPrompts seam and custom-mode guard (19 pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 20:21:29 +02:00

150 lines
6.5 KiB
Swift

import Foundation
import MLXLMCommon
import MLXLLM
/// Layer 2 — the real on-device LLM (rewrite / tone / "Ask" / translate), backed
/// by **MLX** via mlx-swift-examples (verified against tag 2.25.9; SPM package
/// "mlx-libraries", products MLXLLM + MLXLMCommon).
///
/// Loads weights ONLY from a local on-disk snapshot (ModelManager's download
/// directory) — `ModelConfiguration(directory:)` never contacts the network, so
/// inference is fully on-device, honoring the privacy promise.
///
/// API surface used (all confirmed in the 2.25.9 source):
/// ModelConfiguration(directory:) MLXLMCommon/ModelConfiguration.swift
/// LLMModelFactory.shared.loadContainer(...) MLXLLM/LLMModelFactory.swift
/// ModelContainer.perform { context in ... } MLXLMCommon/ModelContainer.swift
/// context.processor.prepare(input: UserInput(chat:)) MLXLMCommon/UserInput.swift
/// MLXLMCommon.generate(...) -> AsyncStream<Generation> MLXLMCommon/Evaluate.swift
final class MLXEngine: AIEngine {
/// Returns the local model snapshot directory if a complete model is present,
/// else nil. Owned by ModelManager; MLXEngine only loads + infers.
private let modelDirectoryProvider: () -> URL?
/// Serializes the (expensive) first load and caches the container.
private let loader = ContainerLoader()
init(modelDirectoryProvider: @escaping () -> URL?) {
self.modelDirectoryProvider = modelDirectoryProvider
}
var isReady: Bool { modelDirectoryProvider() != nil }
// MARK: - AIEngine
func run(_ action: AIAction, on text: String, protectedTerms: [String]) async throws -> String {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return text }
let container = try await container()
let params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
let raw = try await complete(
system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
user: trimmed,
maxTokens: AIPrompts.maxTokens(for: action),
parameters: params, container: container)
return AIPrompts.clean(raw)
}
/// Preload the model container so the first real action doesn't pay the
/// multi-second load. Safe to call repeatedly (the loader caches).
func warmup() async {
_ = try? await container()
}
func runStreaming(_ action: AIAction, on text: String, protectedTerms: [String])
-> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continuation.yield(text); continuation.finish(); return }
do {
let container = try await container()
var params = GenerateParameters(temperature: AIPrompts.temperature(for: action))
params.maxTokens = AIPrompts.maxTokens(for: action)
let system = AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms)
try await container.perform { context in
let input = try await context.processor.prepare(
input: UserInput(chat: [.system(system), .user(trimmed)]))
let stream = try MLXLMCommon.generate(
input: input, parameters: params, context: context)
for await item in stream {
if let chunk = item.chunk { continuation.yield(chunk) }
}
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
func detectTone(_ text: String) async -> Tone {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .professional }
do {
let container = try await container()
let raw = try await complete(
system: AIPrompts.toneClassifierPrompt(), user: trimmed, maxTokens: 8,
parameters: GenerateParameters(temperature: 0.0), container: container)
return AIPrompts.parseTone(raw)
} catch {
return .professional
}
}
// MARK: - Loading
private func container() async throws -> ModelContainer {
guard let dir = modelDirectoryProvider() else { throw AIEngineError.modelNotReady }
return try await loader.container(at: dir)
}
// MARK: - Generation
private func complete(system: String, user: String, maxTokens: Int,
parameters: GenerateParameters,
container: ModelContainer) async throws -> String {
do {
return try await container.perform { context in
let input = try await context.processor.prepare(
input: UserInput(chat: [.system(system), .user(user)]))
var params = parameters
params.maxTokens = maxTokens
var out = ""
let stream = try MLXLMCommon.generate(
input: input, parameters: params, context: context)
for await item in stream {
if let chunk = item.chunk { out += chunk }
}
return out
}
} catch let error as AIEngineError {
throw error
} catch {
throw AIEngineError.generationFailed(error.localizedDescription)
}
}
// Prompt construction, temperature, token budget, and output cleanup now live
// in `AIPrompts` (backend-neutral; see AIPromptKit.swift) so every AIEngine
// backend produces identical prompts and identical output trimming.
}
/// Caches one loaded `ModelContainer` (keyed by directory) and coalesces
/// concurrent first-loads. `ModelContainer` is itself an actor, so generation
/// calls through it are serialized safely.
private actor ContainerLoader {
private var cached: (url: URL, container: ModelContainer)?
func container(at directory: URL) async throws -> ModelContainer {
if let cached, cached.url == directory { return cached.container }
let configuration = ModelConfiguration(directory: directory)
let container = try await LLMModelFactory.shared.loadContainer(configuration: configuration)
cached = (directory, container)
return container
}
}