feat(talk): add provider-agnostic config with legacy compatibility

This commit is contained in:
Nimrod Gutman
2026-02-21 21:47:39 +02:00
committed by Peter Steinberger
parent d1f28c954e
commit d58f71571a
19 changed files with 1003 additions and 109 deletions

View File

@@ -25,7 +25,8 @@ enum GatewaySettingsStore {
private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID"
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey"
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey."
private static let talkElevenLabsApiKeyLegacyAccount = "elevenlabs.apiKey"
static func bootstrapPersistence() {
self.ensureStableInstanceID()
@@ -145,25 +146,52 @@ enum GatewaySettingsStore {
case discovered
}
static func loadTalkElevenLabsApiKey() -> String? {
static func loadTalkProviderApiKey(provider: String) -> String? {
guard let providerId = self.normalizedTalkProviderID(provider) else { return nil }
let account = self.talkProviderApiKeyAccount(providerId: providerId)
let value = KeychainStore.loadString(
service: self.talkService,
account: self.talkElevenLabsApiKeyAccount)?
account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if value?.isEmpty == false { return value }
if providerId == "elevenlabs" {
let legacyValue = KeychainStore.loadString(
service: self.talkService,
account: self.talkElevenLabsApiKeyLegacyAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if legacyValue?.isEmpty == false {
_ = KeychainStore.saveString(legacyValue!, service: self.talkService, account: account)
return legacyValue
}
}
return nil
}
static func saveTalkElevenLabsApiKey(_ apiKey: String?) {
static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) {
guard let providerId = self.normalizedTalkProviderID(provider) else { return }
let account = self.talkProviderApiKeyAccount(providerId: providerId)
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
_ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount)
_ = KeychainStore.delete(service: self.talkService, account: account)
if providerId == "elevenlabs" {
_ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyLegacyAccount)
}
return
}
_ = KeychainStore.saveString(
trimmed,
service: self.talkService,
account: self.talkElevenLabsApiKeyAccount)
_ = KeychainStore.saveString(trimmed, service: self.talkService, account: account)
if providerId == "elevenlabs" {
_ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyLegacyAccount)
}
}
static func loadTalkElevenLabsApiKey() -> String? {
self.loadTalkProviderApiKey(provider: "elevenlabs")
}
static func saveTalkElevenLabsApiKey(_ apiKey: String?) {
self.saveTalkProviderApiKey(apiKey, provider: "elevenlabs")
}
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
@@ -278,6 +306,15 @@ enum GatewaySettingsStore {
"gateway-password.\(instanceId)"
}
private static func talkProviderApiKeyAccount(providerId: String) -> String {
self.talkProviderApiKeyAccountPrefix + providerId
}
private static func normalizedTalkProviderID(_ provider: String) -> String? {
let trimmed = provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private static func ensureStableInstanceID() {
let defaults = UserDefaults.standard

View File

@@ -16,6 +16,7 @@ import Speech
final class TalkModeManager: NSObject {
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
private static let defaultModelIdFallback = "eleven_v3"
private static let defaultTalkProvider = "elevenlabs"
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
var isEnabled: Bool = false
var isListening: Bool = false
@@ -1885,6 +1886,46 @@ extension TalkModeManager {
return trimmed
}
struct TalkProviderConfigSelection {
let provider: String
let config: [String: Any]
let normalizedPayload: Bool
}
private static func normalizedTalkProviderID(_ raw: String?) -> String? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
static func selectTalkProviderConfig(_ talk: [String: Any]?) -> TalkProviderConfigSelection? {
guard let talk else { return nil }
let rawProvider = talk["provider"] as? String
let rawProviders = talk["providers"] as? [String: Any]
let hasNormalized = rawProvider != nil || rawProviders != nil
if hasNormalized {
let providers = rawProviders ?? [:]
let normalizedProviders = providers.reduce(into: [String: [String: Any]]()) { acc, entry in
guard
let providerID = Self.normalizedTalkProviderID(entry.key),
let config = entry.value as? [String: Any]
else { return }
acc[providerID] = config
}
let providerID =
Self.normalizedTalkProviderID(rawProvider) ??
normalizedProviders.keys.sorted().first ??
Self.defaultTalkProvider
return TalkProviderConfigSelection(
provider: providerID,
config: normalizedProviders[providerID] ?? [:],
normalizedPayload: true)
}
return TalkProviderConfigSelection(
provider: Self.defaultTalkProvider,
config: talk,
normalizedPayload: false)
}
func reloadConfig() async {
guard let gateway else { return }
do {
@@ -1892,8 +1933,12 @@ extension TalkModeManager {
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let talk = config["talk"] as? [String: Any]
self.defaultVoiceId = (talk?["voiceId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = talk?["voiceAliases"] as? [String: Any] {
let selection = Self.selectTalkProviderConfig(talk)
let activeProvider = selection?.provider ?? Self.defaultTalkProvider
let activeConfig = selection?.config
self.defaultVoiceId = (activeConfig?["voiceId"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if let aliases = activeConfig?["voiceAliases"] as? [String: Any] {
var resolved: [String: String] = [:]
for (key, value) in aliases {
guard let id = value as? String else { continue }
@@ -1909,22 +1954,28 @@ extension TalkModeManager {
if !self.voiceOverrideActive {
self.currentVoiceId = self.defaultVoiceId
}
let model = (talk?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let model = (activeConfig?["modelId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
self.defaultModelId = (model?.isEmpty == false) ? model : Self.defaultModelIdFallback
if !self.modelOverrideActive {
self.currentModelId = self.defaultModelId
}
self.defaultOutputFormat = (talk?["outputFormat"] as? String)?
self.defaultOutputFormat = (activeConfig?["outputFormat"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let rawConfigApiKey = (activeConfig?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey())
let localApiKey = Self.normalizedTalkApiKey(
GatewaySettingsStore.loadTalkProviderApiKey(provider: activeProvider))
if rawConfigApiKey == Self.redactedConfigSentinel {
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil
GatewayDiagnostics.log("talk config apiKey redacted; using local override if present")
} else {
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey
}
if activeProvider != Self.defaultTalkProvider {
self.apiKey = nil
GatewayDiagnostics.log(
"talk provider '\(activeProvider)' not yet supported on iOS; using system voice fallback")
}
self.gatewayTalkDefaultVoiceId = self.defaultVoiceId
self.gatewayTalkDefaultModelId = self.defaultModelId
self.gatewayTalkApiKeyConfigured = (self.apiKey?.isEmpty == false)
@@ -1932,6 +1983,9 @@ extension TalkModeManager {
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
self.interruptOnSpeech = interrupt
}
if selection?.normalizedPayload == true {
GatewayDiagnostics.log("talk config provider=\(activeProvider)")
}
} catch {
self.defaultModelId = Self.defaultModelIdFallback
if !self.modelOverrideActive {