import Foundation import OSLog /// Zentrale Drehscheibe: hält alle LLM-Backends, wählt nach App- /// Preference + Availability das passende und liefert eine /// einheitliche `generate`/`summarize`-API. /// /// **Routing-Logik in `currentBackend()`:** /// /// 1. App übergibt eine Priority-Liste (`preferredBackends`) — z.B. /// `[.appleFM, .gemmaE2B, .noOp]`. /// 2. Router fragt jedes Backend nach `availability()` und nimmt /// das erste mit `.isSelectable`-Status (verfügbar oder lokal /// gecacht). /// 3. Bei nichts verfügbar → `NoOpBackend`. UI rendert /// `availabilityMap()` separat, damit der User weiß warum. /// /// Apps können das auch komplett umgehen und ein Backend direkt /// instanziieren — z.B. `await AppleFMBackend().generate(...)`. public actor LLMRouter { /// Bequemer App-übergreifender Default mit allen vier Backends. /// Apps mit weniger Backends überschreiben das. public static let shared = LLMRouter() private let appleFM: AppleFMBackend private let noOp: NoOpBackend private let gemmaE2B: GemmaBackend private let gemmaE4B: GemmaBackend /// Priority-Reihenfolge der Backends. Apps können das je nach /// Use-Case justieren — z.B. moodlit will Gemma E2B bevor es FM /// (Creative-Mapping), pageta will FM zuerst (Summary). private var preferred: [LLMBackendID] /// `gemmaAllowsCellular` reicht die App-Einstellung an die /// Gemma-Backends durch — Default `false` (WiFi-only). Bei /// Toggle-Change muss App den Router neu instanziieren. public init( preferred: [LLMBackendID] = [.appleFM, .gemmaE2B, .gemmaE4B, .noOp], gemmaAllowsCellular: Bool = false ) { self.preferred = preferred self.appleFM = AppleFMBackend() self.noOp = NoOpBackend() self.gemmaE2B = GemmaBackend(variant: .e2b, allowsCellular: gemmaAllowsCellular) self.gemmaE4B = GemmaBackend(variant: .e4b, allowsCellular: gemmaAllowsCellular) } public func setPreferred(_ ids: [LLMBackendID]) { preferred = ids } public func backend(for id: LLMBackendID) -> LLMBackend { switch id { case .noOp: noOp case .appleFM: appleFM case .gemmaE2B: gemmaE2B case .gemmaE4B: gemmaE4B } } /// Wählt das erste verfügbare Backend aus der Preference-Liste. /// Bevorzugt strikt `.available` (Modell bereit). Wenn kein /// `.available` gefunden → fällt auf `.requiresDownload`-Fall /// zurück, falls in der Liste. Letzte Notbremse: NoOp. public func currentBackend() async -> LLMBackend { // Erster Pass: `.available` only. for id in preferred { let candidate = backend(for: id) if await candidate.availability() == .available { return candidate } } // Zweiter Pass: irgendwas selectable (auch requires-Download). for id in preferred { let candidate = backend(for: id) let avail = await candidate.availability() if avail.isSelectable { LLMLog.router.notice( "Router: kein .available — pick \(id.rawValue, privacy: .public) (\(String(describing: avail), privacy: .public))" ) return candidate } } LLMLog.router.notice("Router: keine verfügbaren Backends — NoOp") return noOp } // MARK: - Convenience public func generate( prompt: String, instructions: String? = nil, maxTokens: Int = 500 ) async -> String? { let backend = await currentBackend() return await backend.generate( prompt: prompt, instructions: instructions, maxTokens: maxTokens ) } public func summarize(transcript: String) async -> LLMSummary? { let backend = await currentBackend() return await backend.summarize(transcript: transcript) } /// UI-helper: pro Backend-ID den Availability-Status, etwa für die /// Settings-Liste. Parallelisiert über die Backends. public func availabilityMap() async -> [LLMBackendID: LLMAvailability] { var result: [LLMBackendID: LLMAvailability] = [:] for id in LLMBackendID.allCases { result[id] = await backend(for: id).availability() } return result } }