mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(talk): add provider-agnostic config with legacy compatibility
This commit is contained in:
committed by
Peter Steinberger
parent
d1f28c954e
commit
d58f71571a
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,9 +9,15 @@ private struct KeychainEntry: Hashable {
|
||||
|
||||
private let gatewayService = "ai.openclaw.gateway"
|
||||
private let nodeService = "ai.openclaw.node"
|
||||
private let talkService = "ai.openclaw.talk"
|
||||
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
|
||||
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
|
||||
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
|
||||
private let talkElevenLabsLegacyEntry = KeychainEntry(service: talkService, account: "elevenlabs.apiKey")
|
||||
private let talkElevenLabsProviderEntry = KeychainEntry(
|
||||
service: talkService,
|
||||
account: "provider.apiKey.elevenlabs")
|
||||
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
|
||||
|
||||
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -196,4 +202,34 @@ private func restoreKeychain(_ snapshot: [KeychainEntry: String?]) {
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
|
||||
}
|
||||
|
||||
@Test func talkProviderApiKey_genericRoundTrip() {
|
||||
let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry])
|
||||
defer { restoreKeychain(keychainSnapshot) }
|
||||
|
||||
_ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account)
|
||||
|
||||
GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme")
|
||||
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key")
|
||||
|
||||
GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme")
|
||||
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil)
|
||||
}
|
||||
|
||||
@Test func talkProviderApiKey_elevenlabsLegacyFallbackMigratesToProviderKey() {
|
||||
let keychainSnapshot = snapshotKeychain([talkElevenLabsLegacyEntry, talkElevenLabsProviderEntry])
|
||||
defer { restoreKeychain(keychainSnapshot) }
|
||||
|
||||
_ = KeychainStore.delete(service: talkService, account: talkElevenLabsProviderEntry.account)
|
||||
_ = KeychainStore.saveString(
|
||||
"legacy-eleven-key",
|
||||
service: talkService,
|
||||
account: talkElevenLabsLegacyEntry.account)
|
||||
|
||||
let loaded = GatewaySettingsStore.loadTalkProviderApiKey(provider: "elevenlabs")
|
||||
#expect(loaded == "legacy-eleven-key")
|
||||
#expect(
|
||||
KeychainStore.loadString(service: talkService, account: talkElevenLabsProviderEntry.account)
|
||||
== "legacy-eleven-key")
|
||||
}
|
||||
}
|
||||
|
||||
34
apps/ios/Tests/TalkModeConfigParsingTests.swift
Normal file
34
apps/ios/Tests/TalkModeConfigParsingTests.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite struct TalkModeConfigParsingTests {
|
||||
@Test func prefersNormalizedTalkProviderPayload() async {
|
||||
let talk: [String: Any] = [
|
||||
"provider": "elevenlabs",
|
||||
"providers": [
|
||||
"elevenlabs": [
|
||||
"voiceId": "voice-normalized",
|
||||
],
|
||||
],
|
||||
"voiceId": "voice-legacy",
|
||||
]
|
||||
|
||||
let selection = await MainActor.run { TalkModeManager.selectTalkProviderConfig(talk) }
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == true)
|
||||
#expect(selection?.config["voiceId"] as? String == "voice-normalized")
|
||||
}
|
||||
|
||||
@Test func fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() async {
|
||||
let talk: [String: Any] = [
|
||||
"voiceId": "voice-legacy",
|
||||
"apiKey": "legacy-key",
|
||||
]
|
||||
|
||||
let selection = await MainActor.run { TalkModeManager.selectTalkProviderConfig(talk) }
|
||||
#expect(selection?.provider == "elevenlabs")
|
||||
#expect(selection?.normalizedPayload == false)
|
||||
#expect(selection?.config["voiceId"] as? String == "voice-legacy")
|
||||
#expect(selection?.config["apiKey"] as? String == "legacy-key")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user