mana-swift-llm/Sources/ManaLLM/Byok/ByokBackend.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

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