mana-swift-llm/Sources/ManaLLM/ManaLLM.swift
till 069e663847 BYOK TTS (Phase B): native synthesizeSpeech — OpenAI + Gemini
Symmetrisch zur Web-speech.ts. Neue Modalität nach Vision; TTS liefert
Binär-Audio, also ein eigener Adapter (kein SSE-Stream).

- ByokCapability.speak + TTS-Metadaten auf ByokProviderID
  (speechModels/voices/defaults); speak nur für OpenAI + Gemini
- ByokSpeech.swift: byokSynthesizeSpeech-Dispatcher. OpenAI
  /v1/audio/speech → fertiges Format; Gemini :generateContent
  responseModalities AUDIO → rohes 16-bit-PCM (audio/L16) →
  byokPCMToWav verpackt zu WAV, byokParsePCMRate liest Rate aus MIME
- ManaLLM.synthesizeSpeech(text:voice:model:provider:format:) — Facade,
  greift nur bei BYOK + speak-fähigem Provider (sonst nil → Server/
  mana-tts); provider als Wunsch an den Vault-Resolver
- 8 Tests (WAV-Header + Samples unangetastet, parsePCMRate, speak-
  Registry, Facade-Gating inkl. preferred), netz-/keychain-frei.
  swift build + swift test 47/47 grün

Offen: audioguide-native StopEditor-Konsument (Xcode, Tills Hand).

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

362 lines
14 KiB
Swift

import Foundation
import ManaLLMShared
/// High-Level-Facade für lokale LLM-Aufrufe in mana-Apps.
///
/// Apps konsumieren typischerweise nur diese drei statischen Methoden:
///
/// ```swift
/// import ManaLLM
///
/// // Im App-Boot (z.B. @main App init):
/// ManaLLM.configure()
///
/// // Irgendwo später:
/// let summary = await ManaLLM.summarize(longText)
/// let tags = await ManaLLM.classify(text, into: ["#sport", "#politik"])
/// let story = await ManaLLM.generate(
/// prompt: "Schreib eine kurze Reise-Story über Konstanz.",
/// level: .creative
/// )
/// ```
///
/// **Level-Mapping zu Backends:**
/// - `.fast` → AppleFM erst, dann Gemma E2B
/// - `.creative` → Gemma E2B erst, dann AppleFM
/// - `.deep` → Gemma E4B erst, dann Gemma E2B, dann AppleFM
///
/// Niemals throw — bei Fehler `nil` (oder leeres Set). Apps rendern
/// dann eine Fallback-Heuristik.
public enum ManaLLM {
/// Zentraler Router mit Default-Backend-Priority. Apps können
/// das vor dem ersten Call konfigurieren:
/// ```swift
/// await ManaLLM.router.setPreferred([.gemmaE2B, .appleFM])
/// ```
public static let router = LLMRouter.shared
/// Thread-sicherer Halter für den BYOK-Resolver. Solange kein
/// Resolver gesetzt ist (`configureByok` nicht gerufen), bleibt
/// `.byok` inert — die Facade routet rein lokal.
private static let byokBox = ByokFacadeBox()
/// Boot-Side-Effects: HF_HUB_CACHE auf den Shared-Container
/// setzen. Möglichst früh aufrufen (z.B. im `@main`-`init()`).
@discardableResult
public static func configure() -> URL? {
ManaSharedModels.configureHuggingFaceCacheEnv()
}
/// Verdrahtet BYOK für die Facade. **Optional** — ohne diesen Call
/// bleibt „Eigener Schlüssel (BYOK)" wirkungslos. Danach gilt: wenn
/// der Nutzer in den Settings BYOK wählt
/// (`LLMBackendPreferenceStore.current == .byok`) **und** ein
/// Schlüssel hinterlegt ist, gehen `generate`/`summarize`/`classify`
/// direkt vom Gerät zum gewählten Dritt-Anbieter — der Key berührt
/// mana nie. Ist kein Schlüssel da, fällt die Facade still auf die
/// lokale Backend-Priorität zurück.
///
/// Der Default-Vault (`ByokKeyVault()`) teilt die Keychain-Einträge
/// mit der Settings-UI (`ManaLLMSettingsState`), sodass ein dort
/// hinterlegter Schlüssel hier direkt greift.
///
/// ```swift
/// // Im App-Boot, direkt nach ManaLLM.configure():
/// ManaLLM.configureByok()
/// ```
public static func configureByok(
vault: ByokKeyVault = ByokKeyVault(),
defaultProvider: ByokProviderID? = nil
) {
byokBox.set(
resolver: vault.makeResolver(defaultProvider: defaultProvider),
defaultProvider: defaultProvider
)
}
/// Quality-Level für Routing.
public enum Level: Sendable {
case fast // AppleFM zuerst — Standard-Tasks
case creative // Gemma E2B zuerst — Story/Mood/Caption
case deep // Gemma E4B zuerst — Long-Context/Q&A
}
// MARK: - High-Level Operations
/// Freie Generation mit optionalem System-Prompt.
public static func generate(
prompt: String,
instructions: String? = nil,
level: Level = .fast,
maxTokens: Int = 500
) async -> String? {
// BYOK explizit gewählt + Schlüssel vorhanden → Gerät-direkt zum
// Anbieter. Bei nil (kein Key) still auf lokale Priorität zurück.
if let backend = await byokBox.selectedBackend(currentBackend: LLMBackendPreferenceStore.current),
let text = await backend.generate(
prompt: prompt,
instructions: instructions,
maxTokens: maxTokens
)
{
return text
}
let preferred = backendPriority(for: level)
let router = LLMRouter(preferred: preferred)
return await router.generate(
prompt: prompt,
instructions: instructions,
maxTokens: maxTokens
)
}
/// Memoro-Erbe: Headline + Intro für ein langes Transkript.
public static func summarize(
_ text: String,
level: Level = .fast
) async -> LLMSummary? {
if let backend = await byokBox.selectedBackend(currentBackend: LLMBackendPreferenceStore.current),
let summary = await backend.summarize(transcript: text)
{
return summary
}
let preferred = backendPriority(for: level)
let router = LLMRouter(preferred: preferred)
return await router.summarize(transcript: text)
}
/// Klassifikation in vordefinierte Labels. Returnt die Subset-
/// Labels, die laut LLM passen. Bei Parse-Fehler: leeres Set.
public static func classify(
_ text: String,
into labels: [String],
level: Level = .fast
) async -> Set<String> {
guard !labels.isEmpty else { return [] }
let labelList = labels.joined(separator: ", ")
let instructions = """
Du bist ein Klassifikator. Gegeben ein Text, wähle aus der Label-
Liste GENAU die Labels, die zum Text passen. Antworte
ausschließlich mit den passenden Labels, durch Komma getrennt,
ohne Erklärung, ohne Markdown.
Labels: \(labelList)
"""
guard let output = await generate(
prompt: "Text:\n\n\(text)",
instructions: instructions,
level: level,
maxTokens: 100
) else { return [] }
let valid = Set(labels)
let picked = output
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { valid.contains($0) }
return Set(picked)
}
// MARK: - Vision (Phase A)
/// Beschreibt/analysiert ein Bild via **BYOK** — Gerät-direkt zum
/// gewählten Anbieter (OpenAI / Anthropic / Gemini / Mistral). Der
/// Schlüssel berührt mana nie; das Bild geht direkt zum Provider.
///
/// Greift **nur**, wenn der Nutzer „Eigener Schlüssel (BYOK)" gewählt
/// hat (`LLMBackendPreferenceStore.current == .byok`), ein Schlüssel
/// hinterlegt ist und der Provider Vision kann. **Kein lokaler/Server-
/// Fallback hier** (On-Device-Vision ist noch experimentell) — bei
/// `nil` rendert die App ihren eigenen Server-/metered-Pfad.
///
/// `imageData` geht roh rein (JPEG/PNG, `mimeType` z.B. `"image/jpeg"`);
/// der Adapter base64-kodiert ins Provider-Schema. Datenschutz: Bilder
/// gehören zu den sensibleren Inhalten — die App sollte EXIF strippen
/// und die Kantenlänge cappen (COMPLIANCE.md §5).
public static func describeImage(
prompt: String,
imageData: Data,
mimeType: String,
instructions: String? = nil,
maxTokens: Int = 700
) async -> String? {
guard let sel = await byokBox.selectedVision(
currentBackend: LLMBackendPreferenceStore.current
) else { return nil }
var messages: [ByokMessage] = []
if let instructions, !instructions.isEmpty {
messages.append(ByokMessage(role: .system, content: instructions))
}
messages.append(ByokMessage(
role: .user,
parts: [.text(prompt), .imageData(imageData, mimeType: mimeType)]
))
let provider = ProviderResolution.builtin(sel.provider)
return try? await provider.call(
apiKey: sel.apiKey,
model: sel.model,
messages: messages,
temperature: 0.2,
maxTokens: maxTokens,
onToken: nil
)
}
/// Wie `describeImage`, aber nimmt **rohe** Bilddaten (z.B. aus
/// PhotosPicker / Kamera) und strippt EXIF/GPS + downscalet via
/// `ByokImagePrep` selbst — so kann der Native-Konsument den
/// Datenschutz-Schritt nicht vergessen (analog Web
/// `describeImageStructured`, das den EXIF-Strip ebenfalls kapselt).
/// `nil`, wenn das Bild nicht dekodierbar ist oder kein BYOK-Vision-
/// Schlüssel hinterlegt ist.
public static func describeImage(
rawImageData: Data,
prompt: String,
instructions: String? = nil,
maxTokens: Int = 700,
maxEdge: Int = 2048
) async -> String? {
guard let prepared = ByokImagePrep.prepareForVision(rawImageData, maxEdge: maxEdge) else {
return nil
}
return await describeImage(
prompt: prompt,
imageData: prepared.data,
mimeType: prepared.mimeType,
instructions: instructions,
maxTokens: maxTokens
)
}
/// Erzeugt **Audio** aus Text via **BYOK** — Gerät-direkt zum gewählten
/// Anbieter (heute OpenAI + Gemini). Der Schlüssel berührt mana nie.
///
/// Greift **nur**, wenn der Nutzer „Eigener Schlüssel (BYOK)" gewählt
/// hat, ein Schlüssel hinterlegt ist und der Provider TTS (`speak`)
/// kann. **Kein lokaler/Server-Fallback hier** — bei `nil` rendert die
/// App ihren eigenen Server-/mana-tts-Pfad.
///
/// `provider` ist ein optionaler Wunsch (z.B. aus einem App-Picker), der
/// an den Vault-Resolver durchgereicht wird; `voice`/`model` fallen auf
/// die Provider-Defaults zurück. Liefert Roh-Audio-Bytes + MIME (OpenAI
/// meist mp3, Gemini wav) — die App spielt sie ab oder lädt sie hoch.
public static func synthesizeSpeech(
text: String,
voice: String? = nil,
model: String? = nil,
provider: ByokProviderID? = nil,
format: String = "mp3"
) async -> ByokSpeechResult? {
guard let sel = await byokBox.selectedSpeak(
currentBackend: LLMBackendPreferenceStore.current,
preferred: provider
) else { return nil }
return try? await byokSynthesizeSpeech(
sel.provider,
apiKey: sel.apiKey,
model: model ?? sel.model,
text: text,
voice: voice ?? sel.provider.defaultVoice ?? "",
format: format
)
}
// MARK: - Internal
private static func backendPriority(for level: Level) -> [LLMBackendID] {
switch level {
case .fast:
return [.appleFM, .gemmaE2B, .gemmaE4B, .noOp]
case .creative:
return [.gemmaE2B, .appleFM, .gemmaE4B, .noOp]
case .deep:
return [.gemmaE4B, .gemmaE2B, .appleFM, .noOp]
}
}
}
/// Thread-sicherer Halter für den BYOK-Resolver der `ManaLLM`-Facade.
///
/// `@unchecked Sendable`: der mutable State ist durch `NSLock` geschützt,
/// der gespeicherte Resolver ist `@Sendable`. Wir nutzen bewusst keinen
/// Actor — `selectedBackend()` soll synchron-billig sein (ein
/// Preference-Read + Lock), ohne Actor-Hop für jeden Facade-Call.
final class ByokFacadeBox: @unchecked Sendable {
private let lock = NSLock()
private var resolver: ByokKeyResolver?
private var defaultProvider: ByokProviderID?
func set(resolver: @escaping ByokKeyResolver, defaultProvider: ByokProviderID?) {
lock.lock()
defer { lock.unlock() }
self.resolver = resolver
self.defaultProvider = defaultProvider
}
/// Synchroner Snapshot — kapselt das Locking, damit `selectedBackend()`
/// (async) keinen `lock()`-Call im async-Kontext macht (Swift 6 verbietet
/// das).
private func snapshot() -> (resolver: ByokKeyResolver, defaultProvider: ByokProviderID?)? {
lock.lock()
defer { lock.unlock() }
guard let resolver else { return nil }
return (resolver, defaultProvider)
}
/// Liefert ein konfiguriertes `ByokBackend` **nur** wenn (1) der Nutzer
/// BYOK explizit gewählt hat (`currentBackend == .byok`), (2) ein
/// Resolver gesetzt ist und (3) ein Schlüssel hinterlegt ist
/// (`availability == .available`). Sonst `nil` → die Facade routet
/// lokal weiter.
///
/// `currentBackend` wird **übergeben** (statt hier global gelesen),
/// damit die Box rein + ohne globalen State testbar bleibt.
func selectedBackend(currentBackend: LLMBackendID) async -> ByokBackend? {
guard currentBackend == .byok else { return nil }
guard let snap = snapshot() else { return nil }
let backend = ByokBackend(resolver: snap.resolver, defaultProvider: snap.defaultProvider)
guard await backend.availability() == .available else { return nil }
return backend
}
/// BYOK-Auswahl für einen **Vision**-Task: nur wenn (1) der Nutzer
/// BYOK gewählt hat, (2) ein Resolver gesetzt ist, (3) ein Schlüssel
/// aufgelöst wird und (4) der Provider Vision kann. Substituiert das
/// **Vision-Modell** des Providers (das gespeicherte Chat-Modell kann
/// ein Nicht-Vision-Modell sein). Sonst `nil`.
func selectedVision(currentBackend: LLMBackendID) async -> ByokSelection? {
guard currentBackend == .byok else { return nil }
guard let snap = snapshot() else { return nil }
guard let sel = await snap.resolver(snap.defaultProvider) else { return nil }
guard sel.provider.supportedCapabilities.contains(.vision) else { return nil }
return ByokSelection(
provider: sel.provider,
apiKey: sel.apiKey,
model: sel.provider.defaultVisionModel
)
}
/// BYOK-Auswahl für einen **TTS**-Task: nur wenn (1) der Nutzer BYOK
/// gewählt hat, (2) ein Resolver gesetzt ist, (3) ein Schlüssel
/// aufgelöst wird und (4) der Provider `speak` kann. Substituiert das
/// **Speech-Modell** des Providers. `preferred` (z.B. aus einem
/// App-Picker) wird an den Resolver durchgereicht. Sonst `nil`.
func selectedSpeak(
currentBackend: LLMBackendID,
preferred: ByokProviderID? = nil
) async -> ByokSelection? {
guard currentBackend == .byok else { return nil }
guard let snap = snapshot() else { return nil }
guard let sel = await snap.resolver(preferred ?? snap.defaultProvider) else { return nil }
guard sel.provider.supportedCapabilities.contains(.speak),
let speechModel = sel.provider.defaultSpeechModel
else { return nil }
return ByokSelection(
provider: sel.provider,
apiKey: sel.apiKey,
model: speechModel
)
}
}