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

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)
}
}