mana-swift-llm/Sources/ManaLLM/LLMBackend.swift
till fa79a55fe6 feat(byok): native Bring Your Own Key — device-direct, key never touches mana
Symmetric to the web BYOK backend (@mana/browser-llm) and @mana/byok-providers.
A new LLMBackendID.byok lets the user bring their own third-party API key
(OpenAI/Anthropic/Gemini/Mistral); the call streams device-direct to the
provider via URLSession — the key never reaches mana infrastructure.

New Sources/ManaLLM/Byok/:
- ByokTypes: ByokProviderID, ByokSelection, ByokMessage, ByokKeyResolver,
  ByokProvider protocol, ByokError.
- ByokProviders: 4 URLSession SSE adapters (OpenAI/Mistral share an
  OpenAI-compat path; Anthropic + Gemini have their own schemas).
- ByokKeyVault: Keychain store — deliberately app-private, NOT the shared
  group.ev.mana.session; no cross-device sync. makeResolver(…) helper.
- ByokBackend: LLMBackend conformance; unavailable without a resolver;
  prompt/key never logged (PII/secret).

LLMRouter: setByokResolver / setAllowByokInPick. BYOK is never picked
silently (privacy discipline, mirror of web) — reachable via
backend(for: .byok). LLMBackendID.byok.isOnDeviceLLM == false.

Breaking for apps: LLMBackendID has a new .byok case — exhaustive switches
(e.g. settings UI) must handle it.

swift build + swift test green (23 tests, 11 new — network/keychain-free:
provider registry, availability gating, router pick discipline).
Compliance rule (sensitive content not via BYOK by default) in
mana/docs/COMPLIANCE.md §5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:49:37 +02:00

202 lines
6.6 KiB
Swift

import Foundation
/// Uniformes Protocol für alle LLM-Backends in `ManaLLM`.
///
/// Implementierungen:
/// - `NoOpBackend` — kein LLM, erste-Sätze-Fallback.
/// - `AppleFMBackend` — Apple Foundation Models (iOS 26+,
/// Apple-Intelligence-Geräte).
/// - `GemmaBackend` — Gemma 4 E2B/E4B via MLX-Swift, lokal
/// heruntergeladen (oder aus `ManaSharedModels`-Container
/// geladen, wenn die App in der `group.ev.mana.models`-Group ist).
///
/// **API-Design:** `generate(...)` ist die generische Methode für
/// freie Prompts. `summarize(...)` ist eine Memoro-Erbe-Convenience
/// und hat eine Default-Implementation auf Basis von `generate`,
/// damit alle Backends sie automatisch unterstützen.
///
/// `LLMRouter` ist der typische Aufrufer und wählt das Backend nach
/// Capability + Availability + App-Wunsch.
public protocol LLMBackend: Sendable {
var identifier: LLMBackendID { get }
func availability() async -> LLMAvailability
/// Idempotenter Prepare-Schritt: System-Modell-Check (Apple FM),
/// Modell-Download (Gemma), No-Op (NoOpBackend).
func prepare(onProgress: @Sendable @escaping (LLMPrepareUpdate) -> Void) async throws
/// Generische Generation: nimmt einen Prompt, gibt einen String
/// zurück. `instructions` ist optional ein vorangestellter
/// System-Prompt (FoundationModels: `Instructions { ... }`,
/// Gemma: vorne an den User-Prompt geheftet). `maxTokens`
/// limitiert die Output-Länge (Backends können das kappen).
///
/// Niemals throw — bei Fehler `nil`. UI rendert dann
/// "Backend nicht verfügbar".
func generate(
prompt: String,
instructions: String?,
maxTokens: Int
) async -> String?
/// Memoro-Erbe-Convenience: liefert Headline + Intro für ein
/// Transkript. Default-Impl ruft `generate` mit einem
/// Standard-Summary-Prompt — Backends können das überschreiben
/// für Modell-spezifische Optimierungen.
func summarize(transcript: String) async -> LLMSummary?
/// Entfernt ggf. gecachtes Modell von Disk. Default-Impl ist
/// No-Op (für Apple FM, NoOp — die haben keinen
/// Caller-kontrollierten Cache). Gemma überschreibt und löscht
/// den HF-Repo-Pfad im App-Group-Container.
func removeCachedModel() async throws
}
public extension LLMBackend {
/// Default: kein Cache zu löschen. Backends mit gemanagtem Disk-
/// Cache (Gemma) überschreiben.
func removeCachedModel() async throws {}
/// Default-Implementation für `summarize` auf Basis von
/// `generate`. Backends mit optimiertem Summary-Pfad
/// (z.B. AppleFMBackend mit FoundationModels-Schema) überschreiben.
func summarize(transcript: String) async -> LLMSummary? {
let instructions = """
Du bist ein Assistent, der Audio-Transkripte in eine prägnante
Headline und einen kurzen Intro-Satz auf Deutsch destilliert.
Antworte im exakten Format:
HEADLINE: <max 60 Zeichen, prägnant, ohne Punkt>
INTRO: <ein vollständiger Satz, max 200 Zeichen>
"""
let prompt = "Transkript:\n\n\(transcript)"
guard let output = await generate(
prompt: prompt,
instructions: instructions,
maxTokens: 200
) else { return nil }
return LLMSummary.parse(output)
}
}
/// Stabile IDs für `UserDefaults`-Persistenz (rawValue) und
/// SwiftUI-Picker.
public enum LLMBackendID: String, CaseIterable, Sendable {
case noOp
case appleFM
case gemmaE2B
case gemmaE4B
/// Bring Your Own Key — Dritt-Anbieter mit dem Schlüssel des Nutzers,
/// Gerät-direkt. Kein On-Device-Modell; siehe `ByokBackend`.
case byok
public var displayName: String {
switch self {
case .noOp: "Kein LLM (Fallback)"
case .appleFM: "Apple Foundation Models (3 B)"
case .gemmaE2B: "Gemma 4 E2B (2 B, ~1.3 GB)"
case .gemmaE4B: "Gemma 4 E4B (4 B, ~2.5 GB)"
case .byok: "Eigener Schlüssel (BYOK)"
}
}
/// Läuft das Modell auf dem Gerät? BYOK nicht — der Inhalt geht zum
/// Dritt-Anbieter (relevant fürs Datenschutz-Gating).
public var isOnDeviceLLM: Bool {
self != .noOp && self != .byok
}
}
public struct LLMSummary: Equatable, Sendable {
public let headline: String
public let intro: String
public init(headline: String, intro: String) {
self.headline = headline
self.intro = intro
}
/// Parst Default-Impl-Output ("HEADLINE: ...\nINTRO: ..."). Bei
/// kaputtem Format `nil` (Aufrufer fällt auf `firstSentence`
/// zurück).
public static func parse(_ output: String) -> LLMSummary? {
var headline: String?
var intro: String?
for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.uppercased().hasPrefix("HEADLINE:") {
headline = line.dropPrefix(caseInsensitive: "HEADLINE:")
.trimmingCharacters(in: .whitespaces)
} else if line.uppercased().hasPrefix("INTRO:") {
intro = line.dropPrefix(caseInsensitive: "INTRO:")
.trimmingCharacters(in: .whitespaces)
}
}
guard let h = headline, !h.isEmpty, let i = intro, !i.isEmpty else {
return nil
}
return LLMSummary(headline: h, intro: i)
}
}
private extension String {
func dropPrefix(caseInsensitive prefix: String) -> String {
let lower = self.lowercased()
let prefixLower = prefix.lowercased()
guard lower.hasPrefix(prefixLower) else { return self }
return String(self.dropFirst(prefix.count))
}
}
public enum LLMAvailability: Equatable, Sendable {
case available
case requiresDownload(estimatedBytes: Int64)
case downloading(fractionCompleted: Double)
case unavailableDeviceNotEligible
case unavailableModelNotReady
case unavailableAppleIntelligenceNotEnabled
case unavailableOSTooOld
case unavailableMissingDependency(String)
case unknown(String)
/// Soll der Toggle in Settings auswählbar sein?
public var isSelectable: Bool {
switch self {
case .available, .requiresDownload, .downloading:
true
default:
false
}
}
}
public enum LLMPrepareStage: Equatable, Sendable {
case checking
case downloading
case initializing
case ready
}
/// Reichhaltigeres Progress-Event als nur `Double`: enthält optional
/// Byte-Werte. UI kann sinnvolle Anzeige machen auch wenn
/// `fractionCompleted` zu Anfang noch 0 ist (passiert bei HF-LFS-
/// Downloads bis das erste URLSession-Callback kommt). Bytes können
/// `nil` sein, wenn die Quelle keine kennt (Apple FM, NoOp).
public struct LLMPrepareUpdate: Equatable, Sendable {
public let stage: LLMPrepareStage
public let fractionCompleted: Double
public let bytesCompleted: Int64?
public let bytesTotal: Int64?
public init(
stage: LLMPrepareStage,
fractionCompleted: Double,
bytesCompleted: Int64? = nil,
bytesTotal: Int64? = nil
) {
self.stage = stage
self.fractionCompleted = fractionCompleted
self.bytesCompleted = bytesCompleted
self.bytesTotal = bytesTotal
}
}