mana-swift-llm/Tests/ManaLLMTests/ByokTests.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

345 lines
14 KiB
Swift

import Foundation
@testable import ManaLLM
import Testing
/// Tests für die BYOK-Bausteine. Bewusst **netzwerk- und keychain-frei** —
/// die Adapter gehen gegen echte Provider-APIs (manuelle Smoke-Tests),
/// der Vault braucht Keychain-Entitlements. Hier nur die reine Logik:
/// Provider-Registry, Availability-Gating, Router-Pick-Disziplin.
struct ByokTests {
// MARK: - Provider-Registry
@Test func everyProviderHasModelsAndDefaultIsAmongThem() {
for p in ByokProviderID.allCases {
#expect(!p.availableModels.isEmpty)
#expect(p.availableModels.contains(p.defaultModel))
#expect(!p.displayName.isEmpty)
}
}
@Test func providerIdsAreStableRawValues() {
#expect(ByokProviderID.openai.rawValue == "openai")
#expect(ByokProviderID.anthropic.rawValue == "anthropic")
#expect(ByokProviderID.gemini.rawValue == "gemini")
#expect(ByokProviderID.mistral.rawValue == "mistral")
}
// MARK: - On-device-Klassifikation (Datenschutz-Gating)
@Test func byokIsNotAnOnDeviceBackend() {
#expect(LLMBackendID.byok.isOnDeviceLLM == false)
#expect(LLMBackendID.noOp.isOnDeviceLLM == false)
#expect(LLMBackendID.appleFM.isOnDeviceLLM == true)
#expect(LLMBackendID.gemmaE2B.isOnDeviceLLM == true)
}
// MARK: - Availability-Gating
@Test func availabilityIsUnavailableWithoutResolver() async {
let backend = ByokBackend()
let avail = await backend.availability()
#expect(avail.isSelectable == false)
}
@Test func availabilityIsUnavailableWhenResolverReturnsNil() async {
let backend = ByokBackend(resolver: { _ in nil })
let avail = await backend.availability()
#expect(avail.isSelectable == false)
}
@Test func availabilityIsAvailableWithAResolvedKey() async {
let backend = ByokBackend(resolver: { _ in
ByokSelection(provider: .openai, apiKey: "sk-test", model: "gpt-4o-mini")
})
let avail = await backend.availability()
#expect(avail == .available)
}
@Test func resolverReceivesTheDefaultProvider() async {
actor Box { var seen: ByokProviderID?; func set(_ v: ByokProviderID?) {
seen = v
} }
let box = Box()
let backend = ByokBackend(
resolver: { pref in
await box.set(pref)
return ByokSelection(provider: .anthropic, apiKey: "sk", model: "claude-sonnet-4-5")
},
defaultProvider: .anthropic
)
_ = await backend.availability()
#expect(await box.seen == .anthropic)
}
// MARK: - Router-Pick-Disziplin
@Test func routerExposesByokBackendDirectly() async {
let router = LLMRouter(preferred: [.noOp])
let b = await router.backend(for: .byok)
#expect(b.identifier == .byok)
}
@Test func routerNeverPicksByokSilently() async {
let router = LLMRouter(preferred: [.byok, .noOp])
await router.setByokResolver { _ in
ByokSelection(provider: .openai, apiKey: "sk-test", model: "gpt-4o-mini")
}
// allowByokInPick NICHT gesetzt → BYOK ist verfügbar, wird aber
// übersprungen; Router fällt auf NoOp.
let picked = await router.currentBackend()
#expect(picked.identifier == .noOp)
}
@Test func routerPicksByokWhenExplicitlyAllowed() async {
let router = LLMRouter(preferred: [.byok, .noOp])
await router.setByokResolver { _ in
ByokSelection(provider: .openai, apiKey: "sk-test", model: "gpt-4o-mini")
}
await router.setAllowByokInPick(true)
let picked = await router.currentBackend()
#expect(picked.identifier == .byok)
}
// MARK: - Facade-Gating (ByokFacadeBox)
//
// `currentBackend` wird der Box übergeben (kein globaler Store-Read),
// daher race-frei unter Swift-Testing-Parallelität.
@Test func facadeIgnoresByokWhenAnotherBackendSelected() async {
let box = ByokFacadeBox()
box.set(resolver: { _ in
ByokSelection(provider: .openai, apiKey: "sk-test", model: "gpt-4o-mini")
}, defaultProvider: .openai)
// Nutzer hat NICHT BYOK gewählt → Box liefert nichts.
let backend = await box.selectedBackend(currentBackend: .appleFM)
#expect(backend == nil)
}
@Test func facadeIgnoresByokWithoutResolver() async {
let box = ByokFacadeBox()
// BYOK gewählt, aber configureByok nie gerufen → kein Resolver.
let backend = await box.selectedBackend(currentBackend: .byok)
#expect(backend == nil)
}
@Test func facadeIgnoresByokWhenResolverHasNoKey() async {
let box = ByokFacadeBox()
box.set(resolver: { _ in nil }, defaultProvider: nil)
let backend = await box.selectedBackend(currentBackend: .byok)
#expect(backend == nil)
}
@Test func facadeUsesByokWhenSelectedAndKeyPresent() async {
let box = ByokFacadeBox()
box.set(resolver: { _ in
ByokSelection(provider: .anthropic, apiKey: "sk-test", model: "claude-haiku-4-5")
}, defaultProvider: .anthropic)
let backend = await box.selectedBackend(currentBackend: .byok)
#expect(backend?.identifier == .byok)
}
// MARK: - Vision (Phase A): Nachrichten-Modell
@Test func textMessageIsBackCompatAndImageFree() {
let m = ByokMessage(role: .user, content: "hallo")
#expect(m.parts.count == 1)
#expect(m.textContent == "hallo")
#expect(m.hasImages == false)
}
@Test func multimodalMessageReportsImagesAndText() {
let m = ByokMessage(
role: .user,
parts: [.text("was ist das?"), .imageData(Data([1, 2, 3]), mimeType: "image/jpeg")]
)
#expect(m.textContent == "was ist das?")
#expect(m.hasImages == true)
#expect(m.parts.count == 2)
}
// MARK: - Vision: Fähigkeits-Registry
@Test func allProvidersSupportVisionWithModels() {
for p in ByokProviderID.allCases {
#expect(p.supportedCapabilities.contains(.textChat))
#expect(p.supportedCapabilities.contains(.vision))
#expect(!p.visionModels.isEmpty)
#expect(p.visionModels.contains(p.defaultVisionModel))
}
}
// MARK: - Vision: Content-Encoding pro Provider-Schema
private var sampleImage: Data {
Data([0xFF, 0xD8, 0xFF, 0xE0])
}
@Test func openAICompatTextOnlyIsAPlainString() {
let content = byokOpenAICompatContent([.text("hallo"), .text("welt")])
#expect(content as? String == "hallo\nwelt")
}
@Test func openAICompatMultimodalIsArrayWithDataURI() {
let content = byokOpenAICompatContent([
.text("frage"), .imageData(sampleImage, mimeType: "image/jpeg"),
])
let arr = content as? [[String: Any]]
#expect(arr?.count == 2)
#expect(arr?[0]["type"] as? String == "text")
#expect(arr?[1]["type"] as? String == "image_url")
let url = (arr?[1]["image_url"] as? [String: Any])?["url"] as? String
#expect(url?.hasPrefix("data:image/jpeg;base64,") == true)
#expect(url?.contains(sampleImage.base64EncodedString()) == true)
}
@Test func anthropicMultimodalUsesBase64Source() {
let content = byokAnthropicContent([
.text("x"), .imageData(sampleImage, mimeType: "image/png"),
])
let arr = content as? [[String: Any]]
#expect(arr?[1]["type"] as? String == "image")
let source = arr?[1]["source"] as? [String: Any]
#expect(source?["type"] as? String == "base64")
#expect(source?["media_type"] as? String == "image/png")
#expect(source?["data"] as? String == sampleImage.base64EncodedString())
}
@Test func geminiMultimodalUsesInlineData() {
let parts = byokGeminiParts([
.text("x"), .imageData(sampleImage, mimeType: "image/webp"),
])
#expect(parts[0]["text"] as? String == "x")
let inline = parts[1]["inline_data"] as? [String: Any]
#expect(inline?["mime_type"] as? String == "image/webp")
#expect(inline?["data"] as? String == sampleImage.base64EncodedString())
}
// MARK: - Vision: Facade-Gating
@Test func facadeVisionRequiresByokSelectedAndCapable() async {
let box = ByokFacadeBox()
box.set(resolver: { _ in
ByokSelection(provider: .openai, apiKey: "sk-test", model: "gpt-4o-mini")
}, defaultProvider: .openai)
// Andere Quelle gewählt → keine Vision-Selection.
#expect(await box.selectedVision(currentBackend: .appleFM) == nil)
// BYOK gewählt → Vision-Selection mit dem Vision-Modell des Providers.
let sel = await box.selectedVision(currentBackend: .byok)
#expect(sel?.provider == .openai)
#expect(sel?.model == ByokProviderID.openai.defaultVisionModel)
}
@Test func facadeVisionNilWithoutResolver() async {
let box = ByokFacadeBox()
#expect(await box.selectedVision(currentBackend: .byok) == nil)
}
// MARK: - TTS (Phase B): Fähigkeits-Registry
@Test func onlyOpenAIAndGeminiSpeak() {
#expect(ByokProviderID.openai.supportedCapabilities.contains(.speak))
#expect(ByokProviderID.gemini.supportedCapabilities.contains(.speak))
#expect(!ByokProviderID.anthropic.supportedCapabilities.contains(.speak))
#expect(!ByokProviderID.mistral.supportedCapabilities.contains(.speak))
}
@Test func speakProvidersHaveModelsVoicesAndDefaults() {
for p in ByokProviderID.allCases where p.supportedCapabilities.contains(.speak) {
#expect(!p.speechModels.isEmpty)
#expect(!p.voices.isEmpty)
#expect(p.defaultSpeechModel != nil)
#expect(p.defaultVoice != nil)
#expect(p.speechModels.contains(p.defaultSpeechModel!))
#expect(p.voices.contains(p.defaultVoice!))
}
}
@Test func nonSpeakProvidersHaveNoSpeechMetadata() {
for p in ByokProviderID.allCases where !p.supportedCapabilities.contains(.speak) {
#expect(p.speechModels.isEmpty)
#expect(p.voices.isEmpty)
#expect(p.defaultSpeechModel == nil)
#expect(p.defaultVoice == nil)
}
}
// MARK: - TTS: PCM → WAV (Gemini-Kern)
@Test func parsePCMRateReadsRateOrDefaults() {
#expect(byokParsePCMRate("audio/L16;codec=pcm;rate=24000") == 24000)
#expect(byokParsePCMRate("audio/L16;rate=16000") == 16000)
#expect(byokParsePCMRate("audio/L16;codec=pcm") == 24000) // kein rate → Default
#expect(byokParsePCMRate(nil) == 24000)
}
@Test func pcmToWavWritesValidRIFFHeaderBeforeSamples() {
let pcm = Data([1, 2, 3, 4, 5, 6, 7, 8]) // 8 Byte PCM
let wav = byokPCMToWav(pcm, sampleRate: 24000)
#expect(wav.count == 44 + pcm.count)
func ascii(_ start: Int, _ len: Int) -> String {
String(decoding: wav[wav.startIndex + start ..< wav.startIndex + start + len], as: UTF8.self)
}
func u32(_ off: Int) -> Int {
var v: UInt32 = 0
for i in 0 ..< 4 { v |= UInt32(wav[wav.startIndex + off + i]) << (8 * i) }
return Int(v)
}
func u16(_ off: Int) -> Int {
Int(UInt16(wav[wav.startIndex + off]) | (UInt16(wav[wav.startIndex + off + 1]) << 8))
}
#expect(ascii(0, 4) == "RIFF")
#expect(ascii(8, 4) == "WAVE")
#expect(ascii(12, 4) == "fmt ")
#expect(ascii(36, 4) == "data")
#expect(u32(4) == 36 + pcm.count) // RIFF-Größe
#expect(u16(20) == 1) // PCM
#expect(u16(22) == 1) // mono
#expect(u32(24) == 24000) // rate
#expect(u32(28) == 24000 * 2) // byteRate = rate*blockAlign
#expect(u16(32) == 2) // blockAlign
#expect(u16(34) == 16) // bitsPerSample
#expect(u32(40) == pcm.count) // data-Größe
#expect(Array(wav.suffix(pcm.count)) == Array(pcm)) // Samples unangetastet
}
// MARK: - TTS: Facade-Gating
@Test func facadeSpeakRequiresByokSelectedAndCapable() async {
let box = ByokFacadeBox()
box.set(resolver: { _ in
ByokSelection(provider: .openai, apiKey: "sk-test", model: "gpt-4o-mini")
}, defaultProvider: .openai)
// Andere Quelle gewählt → keine Speak-Selection.
#expect(await box.selectedSpeak(currentBackend: .appleFM) == nil)
// BYOK gewählt → Selection mit dem Speech-Modell des Providers.
let sel = await box.selectedSpeak(currentBackend: .byok)
#expect(sel?.provider == .openai)
#expect(sel?.model == ByokProviderID.openai.defaultSpeechModel)
}
@Test func facadeSpeakNilForNonSpeakProvider() async {
let box = ByokFacadeBox()
box.set(resolver: { _ in
ByokSelection(provider: .anthropic, apiKey: "sk-test", model: "claude-sonnet-4-5")
}, defaultProvider: .anthropic)
// Anthropic kann kein TTS → nil, auch bei BYOK-Wahl.
#expect(await box.selectedSpeak(currentBackend: .byok) == nil)
}
@Test func facadeSpeakPassesPreferredProviderToResolver() async {
actor Box { var seen: ByokProviderID?; func set(_ v: ByokProviderID?) { seen = v } }
let seenBox = Box()
let box = ByokFacadeBox()
box.set(resolver: { pref in
await seenBox.set(pref)
return ByokSelection(provider: .gemini, apiKey: "k", model: "gemini-2.5-flash")
}, defaultProvider: .openai)
let sel = await box.selectedSpeak(currentBackend: .byok, preferred: .gemini)
#expect(await seenBox.seen == .gemini)
#expect(sel?.provider == .gemini)
#expect(sel?.model == ByokProviderID.gemini.defaultSpeechModel)
}
}