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>
114 lines
4.3 KiB
Swift
114 lines
4.3 KiB
Swift
import Foundation
|
|
import Security
|
|
|
|
/// Speichert BYOK-Schlüssel in der **Keychain** — verschlüsselt, gerät-
|
|
/// lokal, app-privat.
|
|
///
|
|
/// **Compliance-Invariante:** BYOK-Keys gehören NICHT in die geteilte
|
|
/// `group.ev.mana.session`-Keychain-Gruppe. Fremde Provider-Secrets sind
|
|
/// sensibler als die mana-Session; sie bleiben pro App (oder in einer
|
|
/// eigenen, bewusst gesetzten Access-Group). Default = app-privat, keine
|
|
/// Gruppe. Keine Cross-Device-Synchronisation (`kSecAttrSynchronizable`
|
|
/// bleibt false) — der Nutzer trägt pro Gerät ein.
|
|
///
|
|
/// Ein Eintrag pro Provider (`account = provider.rawValue`), Wert ist
|
|
/// die JSON-kodierte Auswahl (Key + Modell).
|
|
public struct ByokKeyVault: Sendable {
|
|
private let service: String
|
|
private let accessGroup: String?
|
|
|
|
/// - Parameters:
|
|
/// - service: Keychain-Service-Bezeichner. Default `ev.mana.byok`.
|
|
/// - accessGroup: Optionale Keychain-Access-Group. **Nicht** die
|
|
/// Session-Gruppe verwenden (s.o.). Default `nil` = app-privat.
|
|
public init(service: String = "ev.mana.byok", accessGroup: String? = nil) {
|
|
self.service = service
|
|
self.accessGroup = accessGroup
|
|
}
|
|
|
|
private struct Stored: Codable {
|
|
let model: String
|
|
let apiKey: String
|
|
}
|
|
|
|
/// Legt den Schlüssel für einen Provider ab (überschreibt vorhandenen).
|
|
public func save(_ selection: ByokSelection) throws {
|
|
let payload = try JSONEncoder().encode(Stored(model: selection.model, apiKey: selection.apiKey))
|
|
var query = baseQuery(account: selection.provider.rawValue)
|
|
// Erst löschen, dann neu hinzufügen — idempotent über Updates hinweg.
|
|
SecItemDelete(query as CFDictionary)
|
|
query[kSecValueData as String] = payload
|
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
let status = SecItemAdd(query as CFDictionary, nil)
|
|
guard status == errSecSuccess else { throw vaultError(status) }
|
|
}
|
|
|
|
/// Lädt die Auswahl für einen Provider, oder `nil` wenn keiner da ist.
|
|
public func load(_ provider: ByokProviderID) -> ByokSelection? {
|
|
var query = baseQuery(account: provider.rawValue)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var item: CFTypeRef?
|
|
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
|
guard status == errSecSuccess,
|
|
let data = item as? Data,
|
|
let stored = try? JSONDecoder().decode(Stored.self, from: data)
|
|
else { return nil }
|
|
return ByokSelection(provider: provider, apiKey: stored.apiKey, model: stored.model)
|
|
}
|
|
|
|
/// Provider, für die ein Schlüssel hinterlegt ist.
|
|
public func storedProviders() -> [ByokProviderID] {
|
|
ByokProviderID.allCases.filter { load($0) != nil }
|
|
}
|
|
|
|
/// Entfernt den Schlüssel eines Providers.
|
|
public func delete(_ provider: ByokProviderID) {
|
|
SecItemDelete(baseQuery(account: provider.rawValue) as CFDictionary)
|
|
}
|
|
|
|
/// Entfernt alle BYOK-Schlüssel.
|
|
public func deleteAll() {
|
|
for p in ByokProviderID.allCases { delete(p) }
|
|
}
|
|
|
|
private func baseQuery(account: String) -> [String: Any] {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
if let accessGroup {
|
|
query[kSecAttrAccessGroup as String] = accessGroup
|
|
}
|
|
return query
|
|
}
|
|
|
|
private func vaultError(_ status: OSStatus) -> NSError {
|
|
let message = SecCopyErrorMessageString(status, nil) as String? ?? "Keychain-Fehler \(status)"
|
|
return NSError(domain: "ev.mana.byok.vault", code: Int(status), userInfo: [NSLocalizedDescriptionKey: message])
|
|
}
|
|
}
|
|
|
|
public extension ByokKeyVault {
|
|
/// Bequemer Resolver für `LLMRouter` / `ByokBackend`: nimmt den
|
|
/// gewünschten Provider (oder einen Default), liest aus der Keychain.
|
|
///
|
|
/// ```swift
|
|
/// let vault = ByokKeyVault()
|
|
/// await router.setByokResolver(vault.makeResolver(defaultProvider: .anthropic))
|
|
/// ```
|
|
func makeResolver(defaultProvider: ByokProviderID? = nil) -> ByokKeyResolver {
|
|
// `self` ist Sendable (immutable) — Capture ist Swift-6-safe.
|
|
let vault = self
|
|
return { preferred in
|
|
if let preferred, let hit = vault.load(preferred) { return hit }
|
|
if let defaultProvider, let hit = vault.load(defaultProvider) { return hit }
|
|
// Sonst: erster hinterlegter Provider.
|
|
for p in ByokProviderID.allCases {
|
|
if let hit = vault.load(p) { return hit }
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
}
|