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>
100 lines
3.3 KiB
Swift
100 lines
3.3 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import ManaLLM
|
|
|
|
/// 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)
|
|
}
|
|
}
|