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>
91 lines
3 KiB
Swift
91 lines
3 KiB
Swift
import Foundation
|
|
import OSLog
|
|
|
|
/// `LLMBackend`-Konformität für BYOK. Löst den Schlüssel über den
|
|
/// injizierten `ByokKeyResolver` auf und ruft den passenden Adapter
|
|
/// direkt — der Key verlässt das Gerät nur Richtung Anbieter, nie zu mana.
|
|
///
|
|
/// Eine vom Nutzer gewählte Quelle, kein Qualitäts-Level. Wird vom
|
|
/// `LLMRouter` nie still gewählt (siehe `allowByokInPick`); die App
|
|
/// wählt sie bewusst über `router.backend(for: .byok)` oder den
|
|
/// Quelle-Picker. Ohne Resolver ist das Backend `unavailable`.
|
|
public struct ByokBackend: LLMBackend {
|
|
public let identifier: LLMBackendID = .byok
|
|
|
|
private let resolver: ByokKeyResolver?
|
|
private let defaultProvider: ByokProviderID?
|
|
|
|
/// - Parameters:
|
|
/// - resolver: liest die Schlüssel-Auswahl aus dem App-Vault.
|
|
/// `nil` → Backend ist `unavailable`.
|
|
/// - defaultProvider: Wunsch-Provider, wenn ein Call keinen angibt.
|
|
public init(resolver: ByokKeyResolver? = nil, defaultProvider: ByokProviderID? = nil) {
|
|
self.resolver = resolver
|
|
self.defaultProvider = defaultProvider
|
|
}
|
|
|
|
public func availability() async -> LLMAvailability {
|
|
guard let resolver else {
|
|
return .unavailableMissingDependency("Kein BYOK-Key-Resolver konfiguriert")
|
|
}
|
|
guard await resolver(defaultProvider) != nil else {
|
|
return .unavailableMissingDependency("Kein BYOK-Schlüssel hinterlegt")
|
|
}
|
|
return .available
|
|
}
|
|
|
|
public func prepare(onProgress: @Sendable @escaping (LLMPrepareUpdate) -> Void) async throws {
|
|
// Kein Modell-Download — der Anbieter rechnet remote.
|
|
onProgress(LLMPrepareUpdate(stage: .ready, fractionCompleted: 1.0))
|
|
}
|
|
|
|
public func generate(
|
|
prompt: String,
|
|
instructions: String?,
|
|
maxTokens: Int
|
|
) async -> String? {
|
|
guard let resolver else {
|
|
LLMLog.router.notice("ByokBackend.generate ohne Resolver — nil")
|
|
return nil
|
|
}
|
|
guard let selection = await resolver(defaultProvider) else {
|
|
LLMLog.router.notice("ByokBackend.generate ohne hinterlegten Schlüssel — nil")
|
|
return nil
|
|
}
|
|
|
|
var messages: [ByokMessage] = []
|
|
if let instructions, !instructions.isEmpty {
|
|
messages.append(ByokMessage(role: .system, content: instructions))
|
|
}
|
|
messages.append(ByokMessage(role: .user, content: prompt))
|
|
|
|
let provider = ProviderResolution.builtin(selection.provider)
|
|
do {
|
|
return try await provider.call(
|
|
apiKey: selection.apiKey,
|
|
model: selection.model,
|
|
messages: messages,
|
|
temperature: 0.3,
|
|
maxTokens: maxTokens,
|
|
onToken: nil
|
|
)
|
|
} catch {
|
|
// Prompt/Key bewusst NICHT loggen (PII / Secret).
|
|
LLMLog.router.error("ByokBackend.generate fehlgeschlagen: \(String(describing: error), privacy: .public)")
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Kleiner Indirektions-Helper, damit `ByokProvider.builtin` ohne
|
|
/// Protokoll-`Self`-Mehrdeutigkeit aufrufbar ist.
|
|
enum ProviderResolution {
|
|
static func builtin(_ id: ByokProviderID) -> any ByokProvider {
|
|
switch id {
|
|
case .openai: OpenAIByokProvider()
|
|
case .anthropic: AnthropicByokProvider()
|
|
case .gemini: GeminiByokProvider()
|
|
case .mistral: MistralByokProvider()
|
|
}
|
|
}
|
|
}
|