grammargem/Sources/GrammarGem/AI/OllamaEngine.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

177 lines
7.3 KiB
Swift

import Foundation
/// A cross-platform Layer-2 backend that talks to a locally-running **Ollama**
/// server over HTTP. Conforms to the same `AIEngine` seam as `MLXEngine`, reuses
/// the backend-neutral `AIPrompts` (so prompts/output are identical across
/// runtimes), and depends only on `URLSession` — no MLX, no Metal — so it compiles
/// and runs anywhere, including Windows/Linux. This is what converts the AIEngine
/// abstraction from nominal to real and opens the non-Apple-Silicon market.
///
/// Privacy is preserved: Ollama runs the model on the user's own machine on
/// loopback (127.0.0.1); no text leaves the device.
final class OllamaEngine: AIEngine {
private let base: URL
private let model: String
private let session: URLSession
/// `isReady` is read synchronously by the coordinator but readiness for an HTTP
/// backend is an async probe, so we cache the last-known result behind a lock.
private let lock = NSLock()
private var cachedReady = false
init(base: URL = AppConfig.Ollama.baseURL,
model: String = AppConfig.Ollama.defaultModel,
session: URLSession = .shared) {
self.base = base
self.model = model
self.session = session
}
var isReady: Bool {
lock.lock(); defer { lock.unlock() }
return cachedReady
}
private func setReady(_ value: Bool) {
lock.lock(); cachedReady = value; lock.unlock()
}
// MARK: - AIEngine
/// Probe the server and confirm the configured model is pulled. Readiness for
/// Ollama means "server up + model present" (vs. MLX's "weights on disk").
func warmup() async {
setReady(await probeModelPresent())
}
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 raw = try await chat(
system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
user: trimmed,
temperature: AIPrompts.temperature(for: action),
maxTokens: AIPrompts.maxTokens(for: action))
setReady(true)
return AIPrompts.clean(raw)
}
func detectTone(_ text: String) async -> Tone {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return .professional }
do {
let raw = try await chat(
system: AIPrompts.toneClassifierPrompt(), user: trimmed,
temperature: 0.0, maxTokens: 8)
return AIPrompts.parseTone(raw)
} catch {
return .professional
}
}
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 {
try await streamChat(
system: AIPrompts.systemPrompt(for: action, protectedTerms: protectedTerms),
user: trimmed,
temperature: AIPrompts.temperature(for: action),
maxTokens: AIPrompts.maxTokens(for: action),
onChunk: { continuation.yield($0) })
setReady(true)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
// MARK: - HTTP
private func probeModelPresent() async -> Bool {
do {
let (data, response) = try await session.data(from: base.appendingPathComponent("api/tags"))
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return false }
let names = (try JSONDecoder().decode(TagsResponse.self, from: data)).models.map(\.name)
// Match exact or family (e.g. configured "qwen2.5:3b" vs a pulled "qwen2.5:3b-instruct").
return names.contains(model)
|| names.contains { $0.hasPrefix(model) || model.hasPrefix($0) }
} catch {
return false
}
}
private func chat(system: String, user: String,
temperature: Float, maxTokens: Int) async throws -> String {
let request = try makeRequest(path: "api/chat", body: ChatRequest(
model: model,
messages: [.init(role: "system", content: system), .init(role: "user", content: user)],
stream: false,
options: .init(temperature: temperature, num_predict: maxTokens)))
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw AIEngineError.generationFailed("No HTTP response from Ollama at \(base.absoluteString)")
}
guard http.statusCode == 200 else {
throw AIEngineError.generationFailed("Ollama returned HTTP \(http.statusCode)")
}
return (try JSONDecoder().decode(ChatResponse.self, from: data)).message?.content ?? ""
}
private func streamChat(system: String, user: String,
temperature: Float, maxTokens: Int,
onChunk: (String) -> Void) async throws {
let request = try makeRequest(path: "api/chat", body: ChatRequest(
model: model,
messages: [.init(role: "system", content: system), .init(role: "user", content: user)],
stream: true,
options: .init(temperature: temperature, num_predict: maxTokens)))
let (bytes, response) = try await session.bytes(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw AIEngineError.generationFailed("Ollama streaming request failed")
}
// Ollama streams newline-delimited JSON frames, one per token-ish chunk.
for try await line in bytes.lines {
guard let data = line.data(using: .utf8),
let frame = try? JSONDecoder().decode(ChatResponse.self, from: data),
let chunk = frame.message?.content, !chunk.isEmpty
else { continue }
onChunk(chunk)
}
}
private func makeRequest<T: Encodable>(path: String, body: T) throws -> URLRequest {
var request = URLRequest(url: base.appendingPathComponent(path))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(body)
return request
}
// MARK: - Wire types
private struct ChatRequest: Encodable {
let model: String
let messages: [Message]
let stream: Bool
let options: Options
struct Message: Encodable { let role: String; let content: String }
struct Options: Encodable { let temperature: Float; let num_predict: Int }
}
private struct ChatResponse: Decodable {
let message: Message?
struct Message: Decodable { let content: String }
}
private struct TagsResponse: Decodable {
let models: [Model]
struct Model: Decodable { let name: String }
}
}