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>
345 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|