Merge upstream/main and align secrets web-search tests
This commit is contained in:
commit
92b262cb4b
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@ -293,6 +293,10 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/synthetic/**"
|
||||
"extensions: tavily":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/tavily/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@ -45,8 +45,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
|
||||
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
|
||||
- Plugins/Xiaomi: switch the bundled Xiaomi provider to the `/v1` OpenAI-compatible endpoint and add MiMo V2 Pro plus MiMo V2 Omni to the built-in catalog. (#49214) thanks @DJjjjhao.
|
||||
- Android/Talk: move Talk speech synthesis behind gateway `talk.speak`, keep Talk secrets on the gateway, and switch Android playback to final-response audio instead of device-local ElevenLabs streaming. (#50849)
|
||||
- Plugins/Matrix: add `allowBots` room policy so configured Matrix bot accounts can talk to each other, with optional mention-only gating. Thanks @gumadeiras.
|
||||
- Plugins/Matrix: add per-account `allowPrivateNetwork` opt-in for private/internal homeservers, while keeping public cleartext homeservers blocked. Thanks @gumadeiras.
|
||||
- Web tools/Tavily: add Tavily as a bundled web-search provider with dedicated `tavily_search` and `tavily_extract` tools, using canonical plugin-owned config under `plugins.entries.tavily.config.webSearch.*`. (#49200) thanks @lakshyaag-tavily.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
||||
@ -1,338 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioManager
|
||||
import android.media.AudioTrack
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import okhttp3.*
|
||||
import org.json.JSONObject
|
||||
import kotlin.math.max
|
||||
|
||||
/**
|
||||
* Streams text chunks to ElevenLabs WebSocket API and plays audio in real-time.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Create instance with voice/API config
|
||||
* 2. Call [start] to open WebSocket + AudioTrack
|
||||
* 3. Call [sendText] with incremental text chunks as they arrive
|
||||
* 4. Call [finish] when the full response is ready (sends EOS to ElevenLabs)
|
||||
* 5. Call [stop] to cancel/cleanup at any time
|
||||
*
|
||||
* Audio playback begins as soon as the first audio chunk arrives from ElevenLabs,
|
||||
* typically within ~100ms of the first text chunk for eleven_flash_v2_5.
|
||||
*
|
||||
* Note: eleven_v3 does NOT support WebSocket streaming. Use eleven_flash_v2_5
|
||||
* or eleven_flash_v2 for lowest latency.
|
||||
*/
|
||||
class ElevenLabsStreamingTts(
|
||||
private val scope: CoroutineScope,
|
||||
private val voiceId: String,
|
||||
private val apiKey: String,
|
||||
private val modelId: String = "eleven_flash_v2_5",
|
||||
private val outputFormat: String = "pcm_24000",
|
||||
private val sampleRate: Int = 24000,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "ElevenLabsStreamTTS"
|
||||
private const val BASE_URL = "wss://api.elevenlabs.io/v1/text-to-speech"
|
||||
|
||||
/** Models that support WebSocket input streaming */
|
||||
val STREAMING_MODELS = setOf(
|
||||
"eleven_flash_v2_5",
|
||||
"eleven_flash_v2",
|
||||
"eleven_multilingual_v2",
|
||||
"eleven_turbo_v2_5",
|
||||
"eleven_turbo_v2",
|
||||
"eleven_monolingual_v1",
|
||||
)
|
||||
|
||||
fun supportsStreaming(modelId: String): Boolean = modelId in STREAMING_MODELS
|
||||
}
|
||||
|
||||
private val _isPlaying = MutableStateFlow(false)
|
||||
val isPlaying: StateFlow<Boolean> = _isPlaying
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
private var audioTrack: AudioTrack? = null
|
||||
private var trackStarted = false
|
||||
private var client: OkHttpClient? = null
|
||||
@Volatile private var stopped = false
|
||||
@Volatile private var finished = false
|
||||
@Volatile var hasReceivedAudio = false
|
||||
private set
|
||||
private var drainJob: Job? = null
|
||||
|
||||
// Track text already sent so we only send incremental chunks
|
||||
private var sentTextLength = 0
|
||||
@Volatile private var wsReady = false
|
||||
private val pendingText = mutableListOf<String>()
|
||||
|
||||
/**
|
||||
* Open the WebSocket connection and prepare AudioTrack.
|
||||
* Must be called before [sendText].
|
||||
*/
|
||||
fun start() {
|
||||
stopped = false
|
||||
finished = false
|
||||
hasReceivedAudio = false
|
||||
sentTextLength = 0
|
||||
trackStarted = false
|
||||
wsReady = false
|
||||
sentFullText = ""
|
||||
synchronized(pendingText) { pendingText.clear() }
|
||||
|
||||
// Prepare AudioTrack
|
||||
val minBuffer = AudioTrack.getMinBufferSize(
|
||||
sampleRate,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
val bufferSize = max(minBuffer * 2, 8 * 1024)
|
||||
val track = AudioTrack(
|
||||
AudioAttributes.Builder()
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.build(),
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.build(),
|
||||
bufferSize,
|
||||
AudioTrack.MODE_STREAM,
|
||||
AudioManager.AUDIO_SESSION_ID_GENERATE,
|
||||
)
|
||||
if (track.state != AudioTrack.STATE_INITIALIZED) {
|
||||
track.release()
|
||||
Log.e(TAG, "AudioTrack init failed")
|
||||
return
|
||||
}
|
||||
audioTrack = track
|
||||
_isPlaying.value = true
|
||||
|
||||
// Open WebSocket
|
||||
val url = "$BASE_URL/$voiceId/stream-input?model_id=$modelId&output_format=$outputFormat"
|
||||
val okClient = OkHttpClient.Builder()
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
client = okClient
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.header("xi-api-key", apiKey)
|
||||
.build()
|
||||
|
||||
webSocket = okClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
Log.d(TAG, "WebSocket connected")
|
||||
// Send initial config with voice settings
|
||||
val config = JSONObject().apply {
|
||||
put("text", " ")
|
||||
put("voice_settings", JSONObject().apply {
|
||||
put("stability", 0.5)
|
||||
put("similarity_boost", 0.8)
|
||||
put("use_speaker_boost", false)
|
||||
})
|
||||
put("generation_config", JSONObject().apply {
|
||||
put("chunk_length_schedule", org.json.JSONArray(listOf(120, 160, 250, 290)))
|
||||
})
|
||||
}
|
||||
webSocket.send(config.toString())
|
||||
wsReady = true
|
||||
// Flush any text that was queued before WebSocket was ready
|
||||
synchronized(pendingText) {
|
||||
for (queued in pendingText) {
|
||||
val msg = JSONObject().apply { put("text", queued) }
|
||||
webSocket.send(msg.toString())
|
||||
Log.d(TAG, "flushed queued chunk: ${queued.length} chars")
|
||||
}
|
||||
pendingText.clear()
|
||||
}
|
||||
// Send deferred EOS if finish() was called before WebSocket was ready
|
||||
if (finished) {
|
||||
val eos = JSONObject().apply { put("text", "") }
|
||||
webSocket.send(eos.toString())
|
||||
Log.d(TAG, "sent deferred EOS")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
if (stopped) return
|
||||
try {
|
||||
val json = JSONObject(text)
|
||||
val audio = json.optString("audio", "")
|
||||
if (audio.isNotEmpty()) {
|
||||
val pcmBytes = Base64.decode(audio, Base64.DEFAULT)
|
||||
writeToTrack(pcmBytes)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error parsing WebSocket message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
Log.e(TAG, "WebSocket error: ${t.message}")
|
||||
stopped = true
|
||||
cleanup()
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
Log.d(TAG, "WebSocket closed: $code $reason")
|
||||
// Wait for AudioTrack to finish playing buffered audio, then cleanup
|
||||
drainJob = scope.launch(Dispatchers.IO) {
|
||||
drainAudioTrack()
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send incremental text. Call with the full accumulated text so far —
|
||||
* only the new portion (since last send) will be transmitted.
|
||||
*/
|
||||
// Track the full text we've sent so we can detect replacement vs append
|
||||
private var sentFullText = ""
|
||||
|
||||
/**
|
||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
||||
// event from a different thread — not a real divergence. Ignore it.
|
||||
if (sentFullText.startsWith(fullText)) return true
|
||||
* Returns true if text was accepted, false if text diverged (caller should restart).
|
||||
*/
|
||||
@Synchronized
|
||||
fun sendText(fullText: String): Boolean {
|
||||
if (stopped) return false
|
||||
if (finished) return true // Already finishing — not a diverge, don't restart
|
||||
|
||||
// Detect text replacement: if the new text doesn't start with what we already sent,
|
||||
// the stream has diverged (e.g., tool call interrupted and text was replaced).
|
||||
if (sentFullText.isNotEmpty() && !fullText.startsWith(sentFullText)) {
|
||||
// If we already sent a superset of this text, it's just a stale/out-of-order
|
||||
// event from a different thread — not a real divergence. Ignore it.
|
||||
if (sentFullText.startsWith(fullText)) return true
|
||||
Log.d(TAG, "text diverged — sent='${sentFullText.take(60)}' new='${fullText.take(60)}'")
|
||||
return false
|
||||
}
|
||||
|
||||
if (fullText.length > sentTextLength) {
|
||||
val newText = fullText.substring(sentTextLength)
|
||||
sentTextLength = fullText.length
|
||||
sentFullText = fullText
|
||||
|
||||
val ws = webSocket
|
||||
if (ws != null && wsReady) {
|
||||
val msg = JSONObject().apply { put("text", newText) }
|
||||
ws.send(msg.toString())
|
||||
Log.d(TAG, "sent chunk: ${newText.length} chars")
|
||||
} else {
|
||||
// Queue if WebSocket not connected yet (ws null = still connecting, wsReady false = handshake pending)
|
||||
synchronized(pendingText) { pendingText.add(newText) }
|
||||
Log.d(TAG, "queued chunk: ${newText.length} chars (ws not ready)")
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal that no more text is coming. Sends EOS to ElevenLabs.
|
||||
* The WebSocket will close after generating remaining audio.
|
||||
*/
|
||||
@Synchronized
|
||||
fun finish() {
|
||||
if (stopped || finished) return
|
||||
finished = true
|
||||
val ws = webSocket
|
||||
if (ws != null && wsReady) {
|
||||
// Send empty text to signal end of stream
|
||||
val eos = JSONObject().apply { put("text", "") }
|
||||
ws.send(eos.toString())
|
||||
Log.d(TAG, "sent EOS")
|
||||
}
|
||||
// else: WebSocket not ready yet; onOpen will send EOS after flushing queued text
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately stop playback and close everything.
|
||||
*/
|
||||
fun stop() {
|
||||
stopped = true
|
||||
finished = true
|
||||
drainJob?.cancel()
|
||||
drainJob = null
|
||||
webSocket?.cancel()
|
||||
webSocket = null
|
||||
val track = audioTrack
|
||||
audioTrack = null
|
||||
if (track != null) {
|
||||
try {
|
||||
track.pause()
|
||||
track.flush()
|
||||
track.release()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
_isPlaying.value = false
|
||||
client?.dispatcher?.executorService?.shutdown()
|
||||
client = null
|
||||
}
|
||||
|
||||
private fun writeToTrack(pcmBytes: ByteArray) {
|
||||
val track = audioTrack ?: return
|
||||
if (stopped) return
|
||||
|
||||
// Start playback on first audio chunk — avoids underrun
|
||||
if (!trackStarted) {
|
||||
track.play()
|
||||
trackStarted = true
|
||||
hasReceivedAudio = true
|
||||
Log.d(TAG, "AudioTrack started on first chunk")
|
||||
}
|
||||
|
||||
var offset = 0
|
||||
while (offset < pcmBytes.size && !stopped) {
|
||||
val wrote = track.write(pcmBytes, offset, pcmBytes.size - offset)
|
||||
if (wrote <= 0) {
|
||||
if (stopped) return
|
||||
Log.w(TAG, "AudioTrack write returned $wrote")
|
||||
break
|
||||
}
|
||||
offset += wrote
|
||||
}
|
||||
}
|
||||
|
||||
private fun drainAudioTrack() {
|
||||
if (stopped) return
|
||||
// Wait up to 10s for audio to finish playing
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (!stopped && System.currentTimeMillis() < deadline) {
|
||||
// Check if track is still playing
|
||||
val track = audioTrack ?: return
|
||||
if (track.playState != AudioTrack.PLAYSTATE_PLAYING) return
|
||||
try {
|
||||
Thread.sleep(100)
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
val track = audioTrack
|
||||
audioTrack = null
|
||||
if (track != null) {
|
||||
try {
|
||||
track.stop()
|
||||
track.release()
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
_isPlaying.value = false
|
||||
client?.dispatcher?.executorService?.shutdown()
|
||||
client = null
|
||||
}
|
||||
}
|
||||
@ -1,98 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import android.media.MediaDataSource
|
||||
import kotlin.math.min
|
||||
|
||||
internal class StreamingMediaDataSource : MediaDataSource() {
|
||||
private data class Chunk(val start: Long, val data: ByteArray)
|
||||
|
||||
private val lock = Object()
|
||||
private val chunks = ArrayList<Chunk>()
|
||||
private var totalSize: Long = 0
|
||||
private var closed = false
|
||||
private var finished = false
|
||||
private var lastReadIndex = 0
|
||||
|
||||
fun append(data: ByteArray) {
|
||||
if (data.isEmpty()) return
|
||||
synchronized(lock) {
|
||||
if (closed || finished) return
|
||||
val chunk = Chunk(totalSize, data)
|
||||
chunks.add(chunk)
|
||||
totalSize += data.size.toLong()
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun finish() {
|
||||
synchronized(lock) {
|
||||
if (closed) return
|
||||
finished = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
fun fail() {
|
||||
synchronized(lock) {
|
||||
closed = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
|
||||
if (position < 0) return -1
|
||||
synchronized(lock) {
|
||||
while (!closed && !finished && position >= totalSize) {
|
||||
lock.wait()
|
||||
}
|
||||
if (closed) return -1
|
||||
if (position >= totalSize && finished) return -1
|
||||
|
||||
val available = (totalSize - position).toInt()
|
||||
val toRead = min(size, available)
|
||||
var remaining = toRead
|
||||
var destOffset = offset
|
||||
var pos = position
|
||||
|
||||
var index = findChunkIndex(pos)
|
||||
while (remaining > 0 && index < chunks.size) {
|
||||
val chunk = chunks[index]
|
||||
val inChunkOffset = (pos - chunk.start).toInt()
|
||||
if (inChunkOffset >= chunk.data.size) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
val copyLen = min(remaining, chunk.data.size - inChunkOffset)
|
||||
System.arraycopy(chunk.data, inChunkOffset, buffer, destOffset, copyLen)
|
||||
remaining -= copyLen
|
||||
destOffset += copyLen
|
||||
pos += copyLen
|
||||
if (inChunkOffset + copyLen >= chunk.data.size) {
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
return toRead - remaining
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSize(): Long = -1
|
||||
|
||||
override fun close() {
|
||||
synchronized(lock) {
|
||||
closed = true
|
||||
lock.notifyAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun findChunkIndex(position: Long): Int {
|
||||
var index = lastReadIndex
|
||||
while (index < chunks.size) {
|
||||
val chunk = chunks[index]
|
||||
if (position < chunk.start + chunk.data.size) break
|
||||
index++
|
||||
}
|
||||
lastReadIndex = index
|
||||
return index
|
||||
}
|
||||
}
|
||||
@ -4,116 +4,23 @@ import ai.openclaw.app.normalizeMainKey
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
|
||||
internal data class TalkProviderConfigSelection(
|
||||
val provider: String,
|
||||
val config: JsonObject,
|
||||
val normalizedPayload: Boolean,
|
||||
)
|
||||
|
||||
internal data class TalkModeGatewayConfigState(
|
||||
val activeProvider: String,
|
||||
val normalizedPayload: Boolean,
|
||||
val missingResolvedPayload: Boolean,
|
||||
val mainSessionKey: String,
|
||||
val defaultVoiceId: String?,
|
||||
val voiceAliases: Map<String, String>,
|
||||
val defaultModelId: String,
|
||||
val defaultOutputFormat: String,
|
||||
val apiKey: String?,
|
||||
val interruptOnSpeech: Boolean?,
|
||||
val silenceTimeoutMs: Long,
|
||||
)
|
||||
|
||||
internal object TalkModeGatewayConfigParser {
|
||||
private const val defaultTalkProvider = "elevenlabs"
|
||||
|
||||
fun parse(
|
||||
config: JsonObject?,
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultOutputFormatFallback: String,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envKey: String?,
|
||||
): TalkModeGatewayConfigState {
|
||||
fun parse(config: JsonObject?): TalkModeGatewayConfigState {
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
val selection = selectTalkProviderConfig(talk)
|
||||
val activeProvider = selection?.provider ?: defaultProvider
|
||||
val activeConfig = selection?.config
|
||||
val sessionCfg = config?.get("session").asObjectOrNull()
|
||||
val mainKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull())
|
||||
val voice = activeConfig?.get("voiceId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val aliases =
|
||||
activeConfig?.get("voiceAliases").asObjectOrNull()?.entries?.mapNotNull { (key, value) ->
|
||||
val id = value.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
normalizeTalkAliasKey(key).takeIf { it.isNotEmpty() }?.let { it to id }
|
||||
}?.toMap().orEmpty()
|
||||
val model = activeConfig?.get("modelId")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val outputFormat =
|
||||
activeConfig?.get("outputFormat")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val key = activeConfig?.get("apiKey")?.asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
val interrupt = talk?.get("interruptOnSpeech")?.asBooleanOrNull()
|
||||
val silenceTimeoutMs = resolvedSilenceTimeoutMs(talk)
|
||||
|
||||
return TalkModeGatewayConfigState(
|
||||
activeProvider = activeProvider,
|
||||
normalizedPayload = selection?.normalizedPayload == true,
|
||||
missingResolvedPayload = talk != null && selection == null,
|
||||
mainSessionKey = mainKey,
|
||||
defaultVoiceId =
|
||||
if (activeProvider == defaultProvider) {
|
||||
voice ?: envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
voice
|
||||
},
|
||||
voiceAliases = aliases,
|
||||
defaultModelId = model ?: defaultModelIdFallback,
|
||||
defaultOutputFormat = outputFormat ?: defaultOutputFormatFallback,
|
||||
apiKey = key ?: envKey?.takeIf { it.isNotEmpty() },
|
||||
interruptOnSpeech = interrupt,
|
||||
silenceTimeoutMs = silenceTimeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
fun fallback(
|
||||
defaultProvider: String,
|
||||
defaultModelIdFallback: String,
|
||||
defaultOutputFormatFallback: String,
|
||||
envVoice: String?,
|
||||
sagVoice: String?,
|
||||
envKey: String?,
|
||||
): TalkModeGatewayConfigState =
|
||||
TalkModeGatewayConfigState(
|
||||
activeProvider = defaultProvider,
|
||||
normalizedPayload = false,
|
||||
missingResolvedPayload = false,
|
||||
mainSessionKey = "main",
|
||||
defaultVoiceId = envVoice?.takeIf { it.isNotEmpty() } ?: sagVoice?.takeIf { it.isNotEmpty() },
|
||||
voiceAliases = emptyMap(),
|
||||
defaultModelId = defaultModelIdFallback,
|
||||
defaultOutputFormat = defaultOutputFormatFallback,
|
||||
apiKey = envKey?.takeIf { it.isNotEmpty() },
|
||||
interruptOnSpeech = null,
|
||||
silenceTimeoutMs = TalkDefaults.defaultSilenceTimeoutMs,
|
||||
)
|
||||
|
||||
fun selectTalkProviderConfig(talk: JsonObject?): TalkProviderConfigSelection? {
|
||||
if (talk == null) return null
|
||||
selectResolvedTalkProviderConfig(talk)?.let { return it }
|
||||
val rawProvider = talk["provider"].asStringOrNull()
|
||||
val rawProviders = talk["providers"].asObjectOrNull()
|
||||
val hasNormalizedPayload = rawProvider != null || rawProviders != null
|
||||
if (hasNormalizedPayload) {
|
||||
return null
|
||||
}
|
||||
return TalkProviderConfigSelection(
|
||||
provider = defaultTalkProvider,
|
||||
config = talk,
|
||||
normalizedPayload = false,
|
||||
mainSessionKey = normalizeMainKey(sessionCfg?.get("mainKey").asStringOrNull()),
|
||||
interruptOnSpeech = talk?.get("interruptOnSpeech").asBooleanOrNull(),
|
||||
silenceTimeoutMs = resolvedSilenceTimeoutMs(talk),
|
||||
)
|
||||
}
|
||||
|
||||
@ -127,26 +34,8 @@ internal object TalkModeGatewayConfigParser {
|
||||
}
|
||||
return timeout.toLong()
|
||||
}
|
||||
|
||||
private fun selectResolvedTalkProviderConfig(talk: JsonObject): TalkProviderConfigSelection? {
|
||||
val resolved = talk["resolved"].asObjectOrNull() ?: return null
|
||||
val providerId = normalizeTalkProviderId(resolved["provider"].asStringOrNull()) ?: return null
|
||||
return TalkProviderConfigSelection(
|
||||
provider = providerId,
|
||||
config = resolved["config"].asObjectOrNull() ?: buildJsonObject {},
|
||||
normalizedPayload = true,
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeTalkProviderId(raw: String?): String? {
|
||||
val trimmed = raw?.trim()?.lowercase().orEmpty()
|
||||
return trimmed.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeTalkAliasKey(value: String): String =
|
||||
value.trim().lowercase()
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
this?.let { element ->
|
||||
element as? JsonPrimitive
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,122 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
internal data class ElevenLabsVoice(val voiceId: String, val name: String?)
|
||||
|
||||
internal data class TalkModeResolvedVoice(
|
||||
val voiceId: String?,
|
||||
val fallbackVoiceId: String?,
|
||||
val defaultVoiceId: String?,
|
||||
val currentVoiceId: String?,
|
||||
val selectedVoiceName: String? = null,
|
||||
)
|
||||
|
||||
internal object TalkModeVoiceResolver {
|
||||
fun resolveVoiceAlias(value: String?, voiceAliases: Map<String, String>): String? {
|
||||
val trimmed = value?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = normalizeAliasKey(trimmed)
|
||||
voiceAliases[normalized]?.let { return it }
|
||||
if (voiceAliases.values.any { it.equals(trimmed, ignoreCase = true) }) return trimmed
|
||||
return if (isLikelyVoiceId(trimmed)) trimmed else null
|
||||
}
|
||||
|
||||
suspend fun resolveVoiceId(
|
||||
preferred: String?,
|
||||
fallbackVoiceId: String?,
|
||||
defaultVoiceId: String?,
|
||||
currentVoiceId: String?,
|
||||
voiceOverrideActive: Boolean,
|
||||
listVoices: suspend () -> List<ElevenLabsVoice>,
|
||||
): TalkModeResolvedVoice {
|
||||
val trimmed = preferred?.trim().orEmpty()
|
||||
if (trimmed.isNotEmpty()) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = trimmed,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
if (!fallbackVoiceId.isNullOrBlank()) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = fallbackVoiceId,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
|
||||
val first = listVoices().firstOrNull()
|
||||
if (first == null) {
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = null,
|
||||
fallbackVoiceId = fallbackVoiceId,
|
||||
defaultVoiceId = defaultVoiceId,
|
||||
currentVoiceId = currentVoiceId,
|
||||
)
|
||||
}
|
||||
|
||||
return TalkModeResolvedVoice(
|
||||
voiceId = first.voiceId,
|
||||
fallbackVoiceId = first.voiceId,
|
||||
defaultVoiceId = if (defaultVoiceId.isNullOrBlank()) first.voiceId else defaultVoiceId,
|
||||
currentVoiceId = if (voiceOverrideActive) currentVoiceId else first.voiceId,
|
||||
selectedVoiceName = first.name,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun listVoices(apiKey: String, json: Json): List<ElevenLabsVoice> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val url = URL("https://api.elevenlabs.io/v1/voices")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
try {
|
||||
conn.requestMethod = "GET"
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
conn.setRequestProperty("xi-api-key", apiKey)
|
||||
|
||||
val code = conn.responseCode
|
||||
val stream = if (code >= 400) conn.errorStream else conn.inputStream
|
||||
val data = stream?.use { it.readBytes() } ?: byteArrayOf()
|
||||
if (code >= 400) {
|
||||
val message = data.toString(Charsets.UTF_8)
|
||||
throw IllegalStateException("ElevenLabs voices failed: $code $message")
|
||||
}
|
||||
|
||||
val root = json.parseToJsonElement(data.toString(Charsets.UTF_8)).asObjectOrNull()
|
||||
val voices = (root?.get("voices") as? JsonArray) ?: JsonArray(emptyList())
|
||||
voices.mapNotNull { entry ->
|
||||
val obj = entry.asObjectOrNull() ?: return@mapNotNull null
|
||||
val voiceId = obj["voice_id"].asStringOrNull() ?: return@mapNotNull null
|
||||
val name = obj["name"].asStringOrNull()
|
||||
ElevenLabsVoice(voiceId, name)
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLikelyVoiceId(value: String): Boolean {
|
||||
if (value.length < 10) return false
|
||||
return value.all { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
}
|
||||
|
||||
private fun normalizeAliasKey(value: String): String =
|
||||
value.trim().lowercase()
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
(this as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
@ -1,100 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
@Serializable
|
||||
private data class TalkConfigContractFixture(
|
||||
@SerialName("selectionCases") val selectionCases: List<SelectionCase>,
|
||||
@SerialName("timeoutCases") val timeoutCases: List<TimeoutCase>,
|
||||
) {
|
||||
@Serializable
|
||||
data class SelectionCase(
|
||||
val id: String,
|
||||
val defaultProvider: String,
|
||||
val payloadValid: Boolean,
|
||||
val expectedSelection: ExpectedSelection? = null,
|
||||
val talk: JsonObject,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ExpectedSelection(
|
||||
val provider: String,
|
||||
val normalizedPayload: Boolean,
|
||||
val voiceId: String? = null,
|
||||
val apiKey: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TimeoutCase(
|
||||
val id: String,
|
||||
val fallback: Long,
|
||||
val expectedTimeoutMs: Long,
|
||||
val talk: JsonObject,
|
||||
)
|
||||
}
|
||||
|
||||
class TalkModeConfigContractTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun selectionFixtures() {
|
||||
for (fixture in loadFixtures().selectionCases) {
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(fixture.talk)
|
||||
val expected = fixture.expectedSelection
|
||||
if (expected == null) {
|
||||
assertNull(fixture.id, selection)
|
||||
continue
|
||||
}
|
||||
assertNotNull(fixture.id, selection)
|
||||
assertEquals(fixture.id, expected.provider, selection?.provider)
|
||||
assertEquals(fixture.id, expected.normalizedPayload, selection?.normalizedPayload)
|
||||
assertEquals(
|
||||
fixture.id,
|
||||
expected.voiceId,
|
||||
(selection?.config?.get("voiceId") as? JsonPrimitive)?.content,
|
||||
)
|
||||
assertEquals(
|
||||
fixture.id,
|
||||
expected.apiKey,
|
||||
(selection?.config?.get("apiKey") as? JsonPrimitive)?.content,
|
||||
)
|
||||
assertEquals(fixture.id, true, fixture.payloadValid)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun timeoutFixtures() {
|
||||
for (fixture in loadFixtures().timeoutCases) {
|
||||
val timeout = TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(fixture.talk)
|
||||
assertEquals(fixture.id, fixture.expectedTimeoutMs, timeout)
|
||||
assertEquals(fixture.id, TalkDefaults.defaultSilenceTimeoutMs, fixture.fallback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadFixtures(): TalkConfigContractFixture {
|
||||
val fixturePath = findFixtureFile()
|
||||
return json.decodeFromString(File(fixturePath).readText())
|
||||
}
|
||||
|
||||
private fun findFixtureFile(): String {
|
||||
val startDir = System.getProperty("user.dir") ?: error("user.dir unavailable")
|
||||
var current = File(startDir).absoluteFile
|
||||
while (true) {
|
||||
val candidate = File(current, "test-fixtures/talk-config-contract.json")
|
||||
if (candidate.exists()) {
|
||||
return candidate.absolutePath
|
||||
}
|
||||
current = current.parentFile ?: break
|
||||
}
|
||||
error("talk-config-contract.json not found from $startDir")
|
||||
}
|
||||
}
|
||||
@ -2,135 +2,37 @@ package ai.openclaw.app.voice
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TalkModeConfigParsingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun prefersCanonicalResolvedTalkProviderPayload() {
|
||||
val talk =
|
||||
fun readsMainSessionKeyAndInterruptFlag() {
|
||||
val config =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"resolved": {
|
||||
"provider": "elevenlabs",
|
||||
"config": {
|
||||
"voiceId": "voice-resolved"
|
||||
}
|
||||
"talk": {
|
||||
"interruptOnSpeech": true,
|
||||
"silenceTimeoutMs": 1800
|
||||
},
|
||||
"provider": "elevenlabs",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
"session": {
|
||||
"mainKey": "voice-main"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == true)
|
||||
assertEquals("voice-resolved", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
}
|
||||
val parsed = TalkModeGatewayConfigParser.parse(config)
|
||||
|
||||
@Test
|
||||
fun prefersNormalizedTalkProviderPayload() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"provider": "elevenlabs",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
},
|
||||
"voiceId": "voice-legacy"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderMissingFromProviders() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"provider": "acme",
|
||||
"providers": {
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsNormalizedTalkProviderPayloadWhenProviderIsAmbiguous() {
|
||||
val talk =
|
||||
json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"providers": {
|
||||
"acme": {
|
||||
"voiceId": "voice-acme"
|
||||
},
|
||||
"elevenlabs": {
|
||||
"voiceId": "voice-normalized"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
.jsonObject
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertEquals(null, selection)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToLegacyTalkFieldsWhenNormalizedPayloadMissing() {
|
||||
val legacyApiKey = "legacy-key" // pragma: allowlist secret
|
||||
val talk =
|
||||
buildJsonObject {
|
||||
put("voiceId", "voice-legacy")
|
||||
put("apiKey", legacyApiKey) // pragma: allowlist secret
|
||||
}
|
||||
|
||||
val selection = TalkModeGatewayConfigParser.selectTalkProviderConfig(talk)
|
||||
assertNotNull(selection)
|
||||
assertEquals("elevenlabs", selection?.provider)
|
||||
assertTrue(selection?.normalizedPayload == false)
|
||||
assertEquals("voice-legacy", selection?.config?.get("voiceId")?.jsonPrimitive?.content)
|
||||
assertEquals("legacy-key", selection?.config?.get("apiKey")?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsConfiguredSilenceTimeoutMs() {
|
||||
val talk = buildJsonObject { put("silenceTimeoutMs", 1500) }
|
||||
|
||||
assertEquals(1500L, TalkModeGatewayConfigParser.resolvedSilenceTimeoutMs(talk))
|
||||
assertEquals("voice-main", parsed.mainSessionKey)
|
||||
assertEquals(true, parsed.interruptOnSpeech)
|
||||
assertEquals(1800L, parsed.silenceTimeoutMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
package ai.openclaw.app.voice
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class TalkModeVoiceResolverTest {
|
||||
@Test
|
||||
fun resolvesVoiceAliasCaseInsensitively() {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceAlias(
|
||||
" Clawd ",
|
||||
mapOf("clawd" to "voice-123"),
|
||||
)
|
||||
|
||||
assertEquals("voice-123", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptsDirectVoiceIds() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("21m00Tcm4TlvDq8ikWAM", emptyMap())
|
||||
|
||||
assertEquals("21m00Tcm4TlvDq8ikWAM", resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsUnknownAliases() {
|
||||
val resolved = TalkModeVoiceResolver.resolveVoiceAlias("nickname", emptyMap())
|
||||
|
||||
assertNull(resolved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reusesCachedFallbackVoiceBeforeFetchingCatalog() =
|
||||
runBlocking {
|
||||
var fetchCount = 0
|
||||
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = "cached-voice",
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = {
|
||||
fetchCount += 1
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals("cached-voice", resolved.voiceId)
|
||||
assertEquals(0, fetchCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seedsDefaultVoiceFromCatalogWhenNeeded() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = false,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertEquals("voice-1", resolved.fallbackVoiceId)
|
||||
assertEquals("voice-1", resolved.defaultVoiceId)
|
||||
assertEquals("voice-1", resolved.currentVoiceId)
|
||||
assertEquals("First", resolved.selectedVoiceName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun preservesCurrentVoiceWhenOverrideIsActive() =
|
||||
runBlocking {
|
||||
val resolved =
|
||||
TalkModeVoiceResolver.resolveVoiceId(
|
||||
preferred = null,
|
||||
fallbackVoiceId = null,
|
||||
defaultVoiceId = null,
|
||||
currentVoiceId = null,
|
||||
voiceOverrideActive = true,
|
||||
listVoices = { listOf(ElevenLabsVoice("voice-1", "First")) },
|
||||
)
|
||||
|
||||
assertEquals("voice-1", resolved.voiceId)
|
||||
assertNull(resolved.currentVoiceId)
|
||||
}
|
||||
}
|
||||
@ -2012,6 +2012,98 @@ public struct TalkConfigResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let text: String
|
||||
public let voiceid: String?
|
||||
public let modelid: String?
|
||||
public let outputformat: String?
|
||||
public let speed: Double?
|
||||
public let stability: Double?
|
||||
public let similarity: Double?
|
||||
public let style: Double?
|
||||
public let speakerboost: Bool?
|
||||
public let seed: Int?
|
||||
public let normalize: String?
|
||||
public let language: String?
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
voiceid: String?,
|
||||
modelid: String?,
|
||||
outputformat: String?,
|
||||
speed: Double?,
|
||||
stability: Double?,
|
||||
similarity: Double?,
|
||||
style: Double?,
|
||||
speakerboost: Bool?,
|
||||
seed: Int?,
|
||||
normalize: String?,
|
||||
language: String?)
|
||||
{
|
||||
self.text = text
|
||||
self.voiceid = voiceid
|
||||
self.modelid = modelid
|
||||
self.outputformat = outputformat
|
||||
self.speed = speed
|
||||
self.stability = stability
|
||||
self.similarity = similarity
|
||||
self.style = style
|
||||
self.speakerboost = speakerboost
|
||||
self.seed = seed
|
||||
self.normalize = normalize
|
||||
self.language = language
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case text
|
||||
case voiceid = "voiceId"
|
||||
case modelid = "modelId"
|
||||
case outputformat = "outputFormat"
|
||||
case speed
|
||||
case stability
|
||||
case similarity
|
||||
case style
|
||||
case speakerboost = "speakerBoost"
|
||||
case seed
|
||||
case normalize
|
||||
case language
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakResult: Codable, Sendable {
|
||||
public let audiobase64: String
|
||||
public let provider: String
|
||||
public let outputformat: String?
|
||||
public let voicecompatible: Bool?
|
||||
public let mimetype: String?
|
||||
public let fileextension: String?
|
||||
|
||||
public init(
|
||||
audiobase64: String,
|
||||
provider: String,
|
||||
outputformat: String?,
|
||||
voicecompatible: Bool?,
|
||||
mimetype: String?,
|
||||
fileextension: String?)
|
||||
{
|
||||
self.audiobase64 = audiobase64
|
||||
self.provider = provider
|
||||
self.outputformat = outputformat
|
||||
self.voicecompatible = voicecompatible
|
||||
self.mimetype = mimetype
|
||||
self.fileextension = fileextension
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case audiobase64 = "audioBase64"
|
||||
case provider
|
||||
case outputformat = "outputFormat"
|
||||
case voicecompatible = "voiceCompatible"
|
||||
case mimetype = "mimeType"
|
||||
case fileextension = "fileExtension"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@ -2012,6 +2012,98 @@ public struct TalkConfigResult: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakParams: Codable, Sendable {
|
||||
public let text: String
|
||||
public let voiceid: String?
|
||||
public let modelid: String?
|
||||
public let outputformat: String?
|
||||
public let speed: Double?
|
||||
public let stability: Double?
|
||||
public let similarity: Double?
|
||||
public let style: Double?
|
||||
public let speakerboost: Bool?
|
||||
public let seed: Int?
|
||||
public let normalize: String?
|
||||
public let language: String?
|
||||
|
||||
public init(
|
||||
text: String,
|
||||
voiceid: String?,
|
||||
modelid: String?,
|
||||
outputformat: String?,
|
||||
speed: Double?,
|
||||
stability: Double?,
|
||||
similarity: Double?,
|
||||
style: Double?,
|
||||
speakerboost: Bool?,
|
||||
seed: Int?,
|
||||
normalize: String?,
|
||||
language: String?)
|
||||
{
|
||||
self.text = text
|
||||
self.voiceid = voiceid
|
||||
self.modelid = modelid
|
||||
self.outputformat = outputformat
|
||||
self.speed = speed
|
||||
self.stability = stability
|
||||
self.similarity = similarity
|
||||
self.style = style
|
||||
self.speakerboost = speakerboost
|
||||
self.seed = seed
|
||||
self.normalize = normalize
|
||||
self.language = language
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case text
|
||||
case voiceid = "voiceId"
|
||||
case modelid = "modelId"
|
||||
case outputformat = "outputFormat"
|
||||
case speed
|
||||
case stability
|
||||
case similarity
|
||||
case style
|
||||
case speakerboost = "speakerBoost"
|
||||
case seed
|
||||
case normalize
|
||||
case language
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkSpeakResult: Codable, Sendable {
|
||||
public let audiobase64: String
|
||||
public let provider: String
|
||||
public let outputformat: String?
|
||||
public let voicecompatible: Bool?
|
||||
public let mimetype: String?
|
||||
public let fileextension: String?
|
||||
|
||||
public init(
|
||||
audiobase64: String,
|
||||
provider: String,
|
||||
outputformat: String?,
|
||||
voicecompatible: Bool?,
|
||||
mimetype: String?,
|
||||
fileextension: String?)
|
||||
{
|
||||
self.audiobase64 = audiobase64
|
||||
self.provider = provider
|
||||
self.outputformat = outputformat
|
||||
self.voicecompatible = voicecompatible
|
||||
self.mimetype = mimetype
|
||||
self.fileextension = fileextension
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case audiobase64 = "audioBase64"
|
||||
case provider
|
||||
case outputformat = "outputFormat"
|
||||
case voicecompatible = "voiceCompatible"
|
||||
case mimetype = "mimeType"
|
||||
case fileextension = "fileExtension"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
|
||||
@ -1031,6 +1031,7 @@
|
||||
"tools/exec",
|
||||
"tools/exec-approvals",
|
||||
"tools/firecrawl",
|
||||
"tools/tavily",
|
||||
"tools/llm-task",
|
||||
"tools/lobster",
|
||||
"tools/loop-detection",
|
||||
|
||||
@ -38,6 +38,7 @@ Scope intent:
|
||||
- `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||
- `plugins.entries.perplexity.config.webSearch.apiKey`
|
||||
- `plugins.entries.firecrawl.config.webSearch.apiKey`
|
||||
- `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
- `tools.web.search.apiKey`
|
||||
- `tools.web.search.gemini.apiKey`
|
||||
- `tools.web.search.grok.apiKey`
|
||||
|
||||
@ -482,6 +482,13 @@
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
"path": "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
"secretShape": "secret_input",
|
||||
"optIn": true
|
||||
},
|
||||
{
|
||||
"id": "plugins.entries.xai.config.webSearch.apiKey",
|
||||
"configFile": "openclaw.json",
|
||||
|
||||
@ -256,7 +256,7 @@ Enable with `tools.loopDetection.enabled: true` (default is `false`).
|
||||
|
||||
### `web_search`
|
||||
|
||||
Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, or Perplexity.
|
||||
Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, or Tavily.
|
||||
|
||||
Core parameters:
|
||||
|
||||
|
||||
125
docs/tools/tavily.md
Normal file
125
docs/tools/tavily.md
Normal file
@ -0,0 +1,125 @@
|
||||
---
|
||||
summary: "Tavily search and extract tools"
|
||||
read_when:
|
||||
- You want Tavily-backed web search
|
||||
- You need a Tavily API key
|
||||
- You want Tavily as a web_search provider
|
||||
- You want content extraction from URLs
|
||||
title: "Tavily"
|
||||
---
|
||||
|
||||
# Tavily
|
||||
|
||||
OpenClaw can use **Tavily** in two ways:
|
||||
|
||||
- as the `web_search` provider
|
||||
- as explicit plugin tools: `tavily_search` and `tavily_extract`
|
||||
|
||||
Tavily is a search API designed for AI applications, returning structured results
|
||||
optimized for LLM consumption. It supports configurable search depth, topic
|
||||
filtering, domain filters, AI-generated answer summaries, and content extraction
|
||||
from URLs (including JavaScript-rendered pages).
|
||||
|
||||
## Get an API key
|
||||
|
||||
1. Create a Tavily account at [tavily.com](https://tavily.com/).
|
||||
2. Generate an API key in the dashboard.
|
||||
3. Store it in config or set `TAVILY_API_KEY` in the gateway environment.
|
||||
|
||||
## Configure Tavily search
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
tavily: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "tvly-...", // optional if TAVILY_API_KEY is set
|
||||
baseUrl: "https://api.tavily.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "tavily",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Choosing Tavily in onboarding or `openclaw configure --section web` enables
|
||||
the bundled Tavily plugin automatically.
|
||||
- Store Tavily config under `plugins.entries.tavily.config.webSearch.*`.
|
||||
- `web_search` with Tavily supports `query` and `count` (up to 20 results).
|
||||
- For Tavily-specific controls like `search_depth`, `topic`, `include_answer`,
|
||||
or domain filters, use `tavily_search`.
|
||||
|
||||
## Tavily plugin tools
|
||||
|
||||
### `tavily_search`
|
||||
|
||||
Use this when you want Tavily-specific search controls instead of generic
|
||||
`web_search`.
|
||||
|
||||
| Parameter | Description |
|
||||
| ----------------- | --------------------------------------------------------------------- |
|
||||
| `query` | Search query string (keep under 400 characters) |
|
||||
| `search_depth` | `basic` (default, balanced) or `advanced` (highest relevance, slower) |
|
||||
| `topic` | `general` (default), `news` (real-time updates), or `finance` |
|
||||
| `max_results` | Number of results, 1-20 (default: 5) |
|
||||
| `include_answer` | Include an AI-generated answer summary (default: false) |
|
||||
| `time_range` | Filter by recency: `day`, `week`, `month`, or `year` |
|
||||
| `include_domains` | Array of domains to restrict results to |
|
||||
| `exclude_domains` | Array of domains to exclude from results |
|
||||
|
||||
**Search depth:**
|
||||
|
||||
| Depth | Speed | Relevance | Best for |
|
||||
| ---------- | ------ | --------- | ----------------------------------- |
|
||||
| `basic` | Faster | High | General-purpose queries (default) |
|
||||
| `advanced` | Slower | Highest | Precision, specific facts, research |
|
||||
|
||||
### `tavily_extract`
|
||||
|
||||
Use this to extract clean content from one or more URLs. Handles
|
||||
JavaScript-rendered pages and supports query-focused chunking for targeted
|
||||
extraction.
|
||||
|
||||
| Parameter | Description |
|
||||
| ------------------- | ---------------------------------------------------------- |
|
||||
| `urls` | Array of URLs to extract (1-20 per request) |
|
||||
| `query` | Rerank extracted chunks by relevance to this query |
|
||||
| `extract_depth` | `basic` (default, fast) or `advanced` (for JS-heavy pages) |
|
||||
| `chunks_per_source` | Chunks per URL, 1-5 (requires `query`) |
|
||||
| `include_images` | Include image URLs in results (default: false) |
|
||||
|
||||
**Extract depth:**
|
||||
|
||||
| Depth | When to use |
|
||||
| ---------- | ----------------------------------------- |
|
||||
| `basic` | Simple pages - try this first |
|
||||
| `advanced` | JS-rendered SPAs, dynamic content, tables |
|
||||
|
||||
Tips:
|
||||
|
||||
- Max 20 URLs per request. Batch larger lists into multiple calls.
|
||||
- Use `query` + `chunks_per_source` to get only relevant content instead of full pages.
|
||||
- Try `basic` first; fall back to `advanced` if content is missing or incomplete.
|
||||
|
||||
## Choosing the right tool
|
||||
|
||||
| Need | Tool |
|
||||
| ------------------------------------ | ---------------- |
|
||||
| Quick web search, no special options | `web_search` |
|
||||
| Search with depth, topic, AI answers | `tavily_search` |
|
||||
| Extract content from specific URLs | `tavily_extract` |
|
||||
|
||||
See [Web tools](/tools/web) for the full web tool setup and provider comparison.
|
||||
@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Web search + fetch tools (Brave, Firecrawl, Gemini, Grok, Kimi, and Perplexity providers)"
|
||||
summary: "Web search + fetch tools (Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, and Tavily providers)"
|
||||
read_when:
|
||||
- You want to enable web_search or web_fetch
|
||||
- You need provider API key setup
|
||||
@ -11,7 +11,7 @@ title: "Web Tools"
|
||||
|
||||
OpenClaw ships two lightweight web tools:
|
||||
|
||||
- `web_search` — Search the web using Brave Search API, Firecrawl Search, Gemini with Google Search grounding, Grok, Kimi, or Perplexity Search API.
|
||||
- `web_search` — Search the web using Brave Search API, Firecrawl Search, Gemini with Google Search grounding, Grok, Kimi, Perplexity Search API, or Tavily Search API.
|
||||
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
|
||||
|
||||
These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
@ -25,8 +25,9 @@ These are **not** browser automation. For JS-heavy sites or logins, use the
|
||||
(HTML → markdown/text). It does **not** execute JavaScript.
|
||||
- `web_fetch` is enabled by default (unless explicitly disabled).
|
||||
- The bundled Firecrawl plugin also adds `firecrawl_search` and `firecrawl_scrape` when enabled.
|
||||
- The bundled Tavily plugin also adds `tavily_search` and `tavily_extract` when enabled.
|
||||
|
||||
See [Brave Search setup](/tools/brave-search) and [Perplexity Search setup](/tools/perplexity-search) for provider-specific details.
|
||||
See [Brave Search setup](/tools/brave-search), [Perplexity Search setup](/tools/perplexity-search), and [Tavily Search setup](/tools/tavily) for provider-specific details.
|
||||
|
||||
## Choosing a search provider
|
||||
|
||||
@ -38,6 +39,7 @@ See [Brave Search setup](/tools/brave-search) and [Perplexity Search setup](/too
|
||||
| **Grok** | AI-synthesized answers + citations | — | Uses xAI web-grounded responses | `XAI_API_KEY` |
|
||||
| **Kimi** | AI-synthesized answers + citations | — | Uses Moonshot web search | `KIMI_API_KEY` / `MOONSHOT_API_KEY` |
|
||||
| **Perplexity Search API** | Structured results with snippets | `country`, `language`, time, `domain_filter` | Supports content extraction controls; OpenRouter uses Sonar compatibility path | `PERPLEXITY_API_KEY` / `OPENROUTER_API_KEY` |
|
||||
| **Tavily Search API** | Structured results with snippets | Use `tavily_search` for Tavily-specific search options | Search depth, topic filtering, AI answers, URL extraction via `tavily_extract` | `TAVILY_API_KEY` |
|
||||
|
||||
### Auto-detection
|
||||
|
||||
@ -49,6 +51,7 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
|
||||
4. **Kimi** — `KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||
5. **Perplexity** — `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey`
|
||||
6. **Firecrawl** — `FIRECRAWL_API_KEY` env var or `plugins.entries.firecrawl.config.webSearch.apiKey`
|
||||
7. **Tavily** — `TAVILY_API_KEY` env var or `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
|
||||
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
|
||||
|
||||
@ -97,6 +100,7 @@ See [Perplexity Search API Docs](https://docs.perplexity.ai/guides/search-quicks
|
||||
- Grok: `plugins.entries.xai.config.webSearch.apiKey`
|
||||
- Kimi: `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||
- Perplexity: `plugins.entries.perplexity.config.webSearch.apiKey`
|
||||
- Tavily: `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
|
||||
All of these fields also support SecretRef objects.
|
||||
|
||||
@ -108,6 +112,7 @@ All of these fields also support SecretRef objects.
|
||||
- Grok: `XAI_API_KEY`
|
||||
- Kimi: `KIMI_API_KEY` or `MOONSHOT_API_KEY`
|
||||
- Perplexity: `PERPLEXITY_API_KEY` or `OPENROUTER_API_KEY`
|
||||
- Tavily: `TAVILY_API_KEY`
|
||||
|
||||
For a gateway install, put these in `~/.openclaw/.env` (or your service environment). See [Env vars](/help/faq#how-does-openclaw-load-environment-variables).
|
||||
|
||||
@ -176,6 +181,36 @@ For a gateway install, put these in `~/.openclaw/.env` (or your service environm
|
||||
|
||||
When you choose Firecrawl in onboarding or `openclaw configure --section web`, OpenClaw enables the bundled Firecrawl plugin automatically so `web_search`, `firecrawl_search`, and `firecrawl_scrape` are all available.
|
||||
|
||||
**Tavily Search:**
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
tavily: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "tvly-...", // optional if TAVILY_API_KEY is set
|
||||
baseUrl: "https://api.tavily.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
enabled: true,
|
||||
provider: "tavily",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
When you choose Tavily in onboarding or `openclaw configure --section web`, OpenClaw enables the bundled Tavily plugin automatically so `web_search`, `tavily_search`, and `tavily_extract` are all available.
|
||||
|
||||
**Brave LLM Context mode:**
|
||||
|
||||
```json5
|
||||
@ -326,6 +361,7 @@ Search the web using your configured provider.
|
||||
- **Grok**: `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey`
|
||||
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `plugins.entries.moonshot.config.webSearch.apiKey`
|
||||
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `plugins.entries.perplexity.config.webSearch.apiKey`
|
||||
- **Tavily**: `TAVILY_API_KEY` or `plugins.entries.tavily.config.webSearch.apiKey`
|
||||
- All provider key fields above support SecretRef objects.
|
||||
|
||||
### Config
|
||||
@ -369,6 +405,8 @@ If you set `plugins.entries.perplexity.config.webSearch.baseUrl` / `model`, use
|
||||
|
||||
Firecrawl `web_search` supports `query` and `count`. For Firecrawl-specific controls like `sources`, `categories`, result scraping, or scrape timeout, use `firecrawl_search` from the bundled Firecrawl plugin.
|
||||
|
||||
Tavily `web_search` supports `query` and `count` (up to 20 results). For Tavily-specific controls like `search_depth`, `topic`, `include_answer`, or domain filters, use `tavily_search` from the bundled Tavily plugin. For URL content extraction, use `tavily_extract`. See [Tavily](/tools/tavily) for details.
|
||||
|
||||
**Examples:**
|
||||
|
||||
```javascript
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
{
|
||||
"id": "brave",
|
||||
"providerAuthEnvVars": {
|
||||
"brave": ["BRAVE_API_KEY"]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Brave Search API Key",
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
{
|
||||
"id": "firecrawl",
|
||||
"providerAuthEnvVars": {
|
||||
"firecrawl": ["FIRECRAWL_API_KEY"]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Firecrawl Search API Key",
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
{
|
||||
"id": "perplexity",
|
||||
"providerAuthEnvVars": {
|
||||
"perplexity": ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Perplexity API Key",
|
||||
|
||||
41
extensions/tavily/index.test.ts
Normal file
41
extensions/tavily/index.test.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
describe("tavily plugin", () => {
|
||||
it("exports a valid plugin entry with correct id and name", () => {
|
||||
expect(plugin.id).toBe("tavily");
|
||||
expect(plugin.name).toBe("Tavily Plugin");
|
||||
expect(typeof plugin.register).toBe("function");
|
||||
});
|
||||
|
||||
it("registers web search provider and two tools", () => {
|
||||
const registrations: {
|
||||
webSearchProviders: unknown[];
|
||||
tools: unknown[];
|
||||
} = { webSearchProviders: [], tools: [] };
|
||||
|
||||
const mockApi = {
|
||||
registerWebSearchProvider(provider: unknown) {
|
||||
registrations.webSearchProviders.push(provider);
|
||||
},
|
||||
registerTool(tool: unknown) {
|
||||
registrations.tools.push(tool);
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
|
||||
plugin.register(mockApi as never);
|
||||
|
||||
expect(registrations.webSearchProviders).toHaveLength(1);
|
||||
expect(registrations.tools).toHaveLength(2);
|
||||
|
||||
const provider = registrations.webSearchProviders[0] as Record<string, unknown>;
|
||||
expect(provider.id).toBe("tavily");
|
||||
expect(provider.autoDetectOrder).toBe(70);
|
||||
expect(provider.envVars).toEqual(["TAVILY_API_KEY"]);
|
||||
|
||||
const toolNames = registrations.tools.map((t) => (t as Record<string, unknown>).name);
|
||||
expect(toolNames).toContain("tavily_search");
|
||||
expect(toolNames).toContain("tavily_extract");
|
||||
});
|
||||
});
|
||||
15
extensions/tavily/index.ts
Normal file
15
extensions/tavily/index.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/core";
|
||||
import { createTavilyExtractTool } from "./src/tavily-extract-tool.js";
|
||||
import { createTavilyWebSearchProvider } from "./src/tavily-search-provider.js";
|
||||
import { createTavilySearchTool } from "./src/tavily-search-tool.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "tavily",
|
||||
name: "Tavily Plugin",
|
||||
description: "Bundled Tavily search and extract plugin",
|
||||
register(api) {
|
||||
api.registerWebSearchProvider(createTavilyWebSearchProvider());
|
||||
api.registerTool(createTavilySearchTool(api) as AnyAgentTool);
|
||||
api.registerTool(createTavilyExtractTool(api) as AnyAgentTool);
|
||||
},
|
||||
});
|
||||
37
extensions/tavily/openclaw.plugin.json
Normal file
37
extensions/tavily/openclaw.plugin.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"id": "tavily",
|
||||
"skills": ["./skills"],
|
||||
"providerAuthEnvVars": {
|
||||
"tavily": ["TAVILY_API_KEY"]
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Tavily API Key",
|
||||
"help": "Tavily API key for web search and extraction (fallback: TAVILY_API_KEY env var).",
|
||||
"sensitive": true,
|
||||
"placeholder": "tvly-..."
|
||||
},
|
||||
"webSearch.baseUrl": {
|
||||
"label": "Tavily Base URL",
|
||||
"help": "Tavily API base URL override."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": ["string", "object"]
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
extensions/tavily/package.json
Normal file
12
extensions/tavily/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/tavily-plugin",
|
||||
"version": "2026.3.17",
|
||||
"private": true,
|
||||
"description": "OpenClaw Tavily plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
94
extensions/tavily/skills/tavily/SKILL.md
Normal file
94
extensions/tavily/skills/tavily/SKILL.md
Normal file
@ -0,0 +1,94 @@
|
||||
---
|
||||
name: tavily
|
||||
description: Tavily web search, content extraction, and research tools.
|
||||
metadata:
|
||||
{ "openclaw": { "emoji": "🔍", "requires": { "config": ["plugins.entries.tavily.enabled"] } } }
|
||||
---
|
||||
|
||||
# Tavily Tools
|
||||
|
||||
## When to use which tool
|
||||
|
||||
| Need | Tool | When |
|
||||
| ---------------------------- | ---------------- | ------------------------------------------------------------- |
|
||||
| Quick web search | `web_search` | Basic queries, no special options needed |
|
||||
| Search with advanced options | `tavily_search` | Need depth, topic, domain filters, time ranges, or AI answers |
|
||||
| Extract content from URLs | `tavily_extract` | Have specific URLs, need their content |
|
||||
|
||||
## web_search
|
||||
|
||||
Tavily powers this automatically when selected as the search provider. Use for
|
||||
straightforward queries where you don't need Tavily-specific options.
|
||||
|
||||
| Parameter | Description |
|
||||
| --------- | ------------------------ |
|
||||
| `query` | Search query string |
|
||||
| `count` | Number of results (1-20) |
|
||||
|
||||
## tavily_search
|
||||
|
||||
Use when you need fine-grained control over search behavior.
|
||||
|
||||
| Parameter | Description |
|
||||
| ----------------- | --------------------------------------------------------------------- |
|
||||
| `query` | Search query string (keep under 400 characters) |
|
||||
| `search_depth` | `basic` (default, balanced) or `advanced` (highest relevance, slower) |
|
||||
| `topic` | `general` (default), `news` (real-time updates), or `finance` |
|
||||
| `max_results` | Number of results, 1-20 (default: 5) |
|
||||
| `include_answer` | Include an AI-generated answer summary (default: false) |
|
||||
| `time_range` | Filter by recency: `day`, `week`, `month`, or `year` |
|
||||
| `include_domains` | Array of domains to restrict results to |
|
||||
| `exclude_domains` | Array of domains to exclude from results |
|
||||
|
||||
### Search depth
|
||||
|
||||
| Depth | Speed | Relevance | Best for |
|
||||
| ---------- | ------ | --------- | -------------------------------------------- |
|
||||
| `basic` | Faster | High | General-purpose queries (default) |
|
||||
| `advanced` | Slower | Highest | Precision, specific facts, detailed research |
|
||||
|
||||
### Tips
|
||||
|
||||
- **Keep queries under 400 characters** — think search query, not prompt.
|
||||
- **Break complex queries into sub-queries** for better results.
|
||||
- **Use `include_domains`** to focus on trusted sources.
|
||||
- **Use `time_range`** for recent information (news, current events).
|
||||
- **Use `include_answer`** when you need a quick synthesized answer.
|
||||
|
||||
## tavily_extract
|
||||
|
||||
Use when you have specific URLs and need their content. Handles JavaScript-rendered
|
||||
pages and returns clean markdown. Supports query-focused chunking for targeted
|
||||
extraction.
|
||||
|
||||
| Parameter | Description |
|
||||
| ------------------- | ------------------------------------------------------------------ |
|
||||
| `urls` | Array of URLs to extract (1-20 per request) |
|
||||
| `query` | Rerank extracted chunks by relevance to this query |
|
||||
| `extract_depth` | `basic` (default, fast) or `advanced` (for JS-heavy pages, tables) |
|
||||
| `chunks_per_source` | Chunks per URL, 1-5 (requires `query`) |
|
||||
| `include_images` | Include image URLs in results (default: false) |
|
||||
|
||||
### Extract depth
|
||||
|
||||
| Depth | When to use |
|
||||
| ---------- | ----------------------------------------------------------- |
|
||||
| `basic` | Simple pages — try this first |
|
||||
| `advanced` | JS-rendered SPAs, dynamic content, tables, embedded content |
|
||||
|
||||
### Tips
|
||||
|
||||
- **Max 20 URLs per request** — batch larger lists into multiple calls.
|
||||
- **Use `query` + `chunks_per_source`** to get only relevant content instead of full pages.
|
||||
- **Try `basic` first**, fall back to `advanced` if content is missing or incomplete.
|
||||
- If `tavily_search` results already contain the snippets you need, skip the extract step.
|
||||
|
||||
## Choosing the right workflow
|
||||
|
||||
Follow this escalation pattern — start simple, escalate only when needed:
|
||||
|
||||
1. **`web_search`** — Quick lookup, no special options needed.
|
||||
2. **`tavily_search`** — Need depth control, topic filtering, domain filters, time ranges, or AI answers.
|
||||
3. **`tavily_extract`** — Have specific URLs, need their full content or targeted chunks.
|
||||
|
||||
Combine search + extract when you need to find pages first, then get their full content.
|
||||
71
extensions/tavily/src/config.ts
Normal file
71
extensions/tavily/src/config.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth";
|
||||
|
||||
export const DEFAULT_TAVILY_BASE_URL = "https://api.tavily.com";
|
||||
export const DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS = 30;
|
||||
export const DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS = 60;
|
||||
|
||||
type TavilySearchConfig =
|
||||
| {
|
||||
apiKey?: unknown;
|
||||
baseUrl?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
type PluginEntryConfig = {
|
||||
webSearch?: {
|
||||
apiKey?: unknown;
|
||||
baseUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveTavilySearchConfig(cfg?: OpenClawConfig): TavilySearchConfig {
|
||||
const pluginConfig = cfg?.plugins?.entries?.tavily?.config as PluginEntryConfig;
|
||||
const pluginWebSearch = pluginConfig?.webSearch;
|
||||
if (pluginWebSearch && typeof pluginWebSearch === "object" && !Array.isArray(pluginWebSearch)) {
|
||||
return pluginWebSearch;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeConfiguredSecret(value: unknown, path: string): string | undefined {
|
||||
return normalizeSecretInput(
|
||||
normalizeResolvedSecretInputString({
|
||||
value,
|
||||
path,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTavilyApiKey(cfg?: OpenClawConfig): string | undefined {
|
||||
const search = resolveTavilySearchConfig(cfg);
|
||||
return (
|
||||
normalizeConfiguredSecret(search?.apiKey, "plugins.entries.tavily.config.webSearch.apiKey") ||
|
||||
normalizeSecretInput(process.env.TAVILY_API_KEY) ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveTavilyBaseUrl(cfg?: OpenClawConfig): string {
|
||||
const search = resolveTavilySearchConfig(cfg);
|
||||
const configured =
|
||||
(typeof search?.baseUrl === "string" ? search.baseUrl.trim() : "") ||
|
||||
normalizeSecretInput(process.env.TAVILY_BASE_URL) ||
|
||||
"";
|
||||
return configured || DEFAULT_TAVILY_BASE_URL;
|
||||
}
|
||||
|
||||
export function resolveTavilySearchTimeoutSeconds(override?: number): number {
|
||||
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
||||
return Math.floor(override);
|
||||
}
|
||||
return DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS;
|
||||
}
|
||||
|
||||
export function resolveTavilyExtractTimeoutSeconds(override?: number): number {
|
||||
if (typeof override === "number" && Number.isFinite(override) && override > 0) {
|
||||
return Math.floor(override);
|
||||
}
|
||||
return DEFAULT_TAVILY_EXTRACT_TIMEOUT_SECONDS;
|
||||
}
|
||||
286
extensions/tavily/src/tavily-client.ts
Normal file
286
extensions/tavily/src/tavily-client.ts
Normal file
@ -0,0 +1,286 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { withTrustedWebToolsEndpoint } from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
DEFAULT_CACHE_TTL_MINUTES,
|
||||
normalizeCacheKey,
|
||||
readCache,
|
||||
readResponseText,
|
||||
resolveCacheTtlMs,
|
||||
writeCache,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { wrapExternalContent, wrapWebContent } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
DEFAULT_TAVILY_BASE_URL,
|
||||
resolveTavilyApiKey,
|
||||
resolveTavilyBaseUrl,
|
||||
resolveTavilyExtractTimeoutSeconds,
|
||||
resolveTavilySearchTimeoutSeconds,
|
||||
} from "./config.js";
|
||||
|
||||
const SEARCH_CACHE = new Map<
|
||||
string,
|
||||
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
|
||||
>();
|
||||
const EXTRACT_CACHE = new Map<
|
||||
string,
|
||||
{ value: Record<string, unknown>; expiresAt: number; insertedAt: number }
|
||||
>();
|
||||
const DEFAULT_SEARCH_COUNT = 5;
|
||||
const DEFAULT_ERROR_MAX_BYTES = 64_000;
|
||||
|
||||
export type TavilySearchParams = {
|
||||
cfg?: OpenClawConfig;
|
||||
query: string;
|
||||
searchDepth?: string;
|
||||
topic?: string;
|
||||
maxResults?: number;
|
||||
includeAnswer?: boolean;
|
||||
timeRange?: string;
|
||||
includeDomains?: string[];
|
||||
excludeDomains?: string[];
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
export type TavilyExtractParams = {
|
||||
cfg?: OpenClawConfig;
|
||||
urls: string[];
|
||||
query?: string;
|
||||
extractDepth?: string;
|
||||
chunksPerSource?: number;
|
||||
includeImages?: boolean;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
function resolveEndpoint(baseUrl: string, pathname: string): string {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return `${DEFAULT_TAVILY_BASE_URL}${pathname}`;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
// Always append the endpoint pathname to the base URL path,
|
||||
// supporting both bare hosts and reverse-proxy path prefixes.
|
||||
url.pathname = url.pathname.replace(/\/$/, "") + pathname;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return `${DEFAULT_TAVILY_BASE_URL}${pathname}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function postTavilyJson(params: {
|
||||
baseUrl: string;
|
||||
pathname: string;
|
||||
apiKey: string;
|
||||
body: Record<string, unknown>;
|
||||
timeoutSeconds: number;
|
||||
errorLabel: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const endpoint = resolveEndpoint(params.baseUrl, params.pathname);
|
||||
return await withTrustedWebToolsEndpoint(
|
||||
{
|
||||
url: endpoint,
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
},
|
||||
},
|
||||
async ({ response }) => {
|
||||
if (!response.ok) {
|
||||
const detail = await readResponseText(response, { maxBytes: DEFAULT_ERROR_MAX_BYTES });
|
||||
throw new Error(
|
||||
`${params.errorLabel} API error (${response.status}): ${detail.text || response.statusText}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function runTavilySearch(
|
||||
params: TavilySearchParams,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const apiKey = resolveTavilyApiKey(params.cfg);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"web_search (tavily) needs a Tavily API key. Set TAVILY_API_KEY in the Gateway environment, or configure plugins.entries.tavily.config.webSearch.apiKey.",
|
||||
);
|
||||
}
|
||||
const count =
|
||||
typeof params.maxResults === "number" && Number.isFinite(params.maxResults)
|
||||
? Math.max(1, Math.min(20, Math.floor(params.maxResults)))
|
||||
: DEFAULT_SEARCH_COUNT;
|
||||
const timeoutSeconds = resolveTavilySearchTimeoutSeconds(params.timeoutSeconds);
|
||||
const baseUrl = resolveTavilyBaseUrl(params.cfg);
|
||||
|
||||
const cacheKey = normalizeCacheKey(
|
||||
JSON.stringify({
|
||||
type: "tavily-search",
|
||||
q: params.query,
|
||||
count,
|
||||
baseUrl,
|
||||
searchDepth: params.searchDepth,
|
||||
topic: params.topic,
|
||||
includeAnswer: params.includeAnswer,
|
||||
timeRange: params.timeRange,
|
||||
includeDomains: params.includeDomains,
|
||||
excludeDomains: params.excludeDomains,
|
||||
}),
|
||||
);
|
||||
const cached = readCache(SEARCH_CACHE, cacheKey);
|
||||
if (cached) {
|
||||
return { ...cached.value, cached: true };
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
max_results: count,
|
||||
};
|
||||
if (params.searchDepth) body.search_depth = params.searchDepth;
|
||||
if (params.topic) body.topic = params.topic;
|
||||
if (params.includeAnswer) body.include_answer = true;
|
||||
if (params.timeRange) body.time_range = params.timeRange;
|
||||
if (params.includeDomains?.length) body.include_domains = params.includeDomains;
|
||||
if (params.excludeDomains?.length) body.exclude_domains = params.excludeDomains;
|
||||
|
||||
const start = Date.now();
|
||||
const payload = await postTavilyJson({
|
||||
baseUrl,
|
||||
pathname: "/search",
|
||||
apiKey,
|
||||
body,
|
||||
timeoutSeconds,
|
||||
errorLabel: "Tavily Search",
|
||||
});
|
||||
|
||||
const rawResults = Array.isArray(payload.results) ? payload.results : [];
|
||||
const results = rawResults.map((r: Record<string, unknown>) => ({
|
||||
title: typeof r.title === "string" ? wrapWebContent(r.title, "web_search") : "",
|
||||
url: typeof r.url === "string" ? r.url : "",
|
||||
snippet: typeof r.content === "string" ? wrapWebContent(r.content, "web_search") : "",
|
||||
score: typeof r.score === "number" ? r.score : undefined,
|
||||
...(typeof r.published_date === "string" ? { published: r.published_date } : {}),
|
||||
}));
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
query: params.query,
|
||||
provider: "tavily",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "tavily",
|
||||
wrapped: true,
|
||||
},
|
||||
results,
|
||||
};
|
||||
if (typeof payload.answer === "string" && payload.answer) {
|
||||
result.answer = wrapWebContent(payload.answer, "web_search");
|
||||
}
|
||||
|
||||
writeCache(
|
||||
SEARCH_CACHE,
|
||||
cacheKey,
|
||||
result,
|
||||
resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runTavilyExtract(
|
||||
params: TavilyExtractParams,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const apiKey = resolveTavilyApiKey(params.cfg);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"tavily_extract needs a Tavily API key. Set TAVILY_API_KEY in the Gateway environment, or configure plugins.entries.tavily.config.webSearch.apiKey.",
|
||||
);
|
||||
}
|
||||
const baseUrl = resolveTavilyBaseUrl(params.cfg);
|
||||
const timeoutSeconds = resolveTavilyExtractTimeoutSeconds(params.timeoutSeconds);
|
||||
|
||||
const cacheKey = normalizeCacheKey(
|
||||
JSON.stringify({
|
||||
type: "tavily-extract",
|
||||
urls: params.urls,
|
||||
baseUrl,
|
||||
query: params.query,
|
||||
extractDepth: params.extractDepth,
|
||||
chunksPerSource: params.chunksPerSource,
|
||||
includeImages: params.includeImages,
|
||||
}),
|
||||
);
|
||||
const cached = readCache(EXTRACT_CACHE, cacheKey);
|
||||
if (cached) {
|
||||
return { ...cached.value, cached: true };
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { urls: params.urls };
|
||||
if (params.query) body.query = params.query;
|
||||
if (params.extractDepth) body.extract_depth = params.extractDepth;
|
||||
if (params.chunksPerSource) body.chunks_per_source = params.chunksPerSource;
|
||||
if (params.includeImages) body.include_images = true;
|
||||
|
||||
const start = Date.now();
|
||||
const payload = await postTavilyJson({
|
||||
baseUrl,
|
||||
pathname: "/extract",
|
||||
apiKey,
|
||||
body,
|
||||
timeoutSeconds,
|
||||
errorLabel: "Tavily Extract",
|
||||
});
|
||||
|
||||
const rawResults = Array.isArray(payload.results) ? payload.results : [];
|
||||
const results = rawResults.map((r: Record<string, unknown>) => ({
|
||||
url: typeof r.url === "string" ? r.url : "",
|
||||
rawContent:
|
||||
typeof r.raw_content === "string"
|
||||
? wrapExternalContent(r.raw_content, { source: "web_fetch", includeWarning: false })
|
||||
: "",
|
||||
...(typeof r.content === "string"
|
||||
? { content: wrapExternalContent(r.content, { source: "web_fetch", includeWarning: false }) }
|
||||
: {}),
|
||||
...(Array.isArray(r.images)
|
||||
? {
|
||||
images: (r.images as string[]).map((img) =>
|
||||
wrapExternalContent(String(img), { source: "web_fetch", includeWarning: false }),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const failedResults = Array.isArray(payload.failed_results) ? payload.failed_results : [];
|
||||
|
||||
const result: Record<string, unknown> = {
|
||||
provider: "tavily",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_fetch",
|
||||
provider: "tavily",
|
||||
wrapped: true,
|
||||
},
|
||||
results,
|
||||
...(failedResults.length > 0 ? { failedResults } : {}),
|
||||
};
|
||||
|
||||
writeCache(
|
||||
EXTRACT_CACHE,
|
||||
cacheKey,
|
||||
result,
|
||||
resolveCacheTtlMs(undefined, DEFAULT_CACHE_TTL_MINUTES),
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
postTavilyJson,
|
||||
};
|
||||
53
extensions/tavily/src/tavily-extract-tool.test.ts
Normal file
53
extensions/tavily/src/tavily-extract-tool.test.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./tavily-client.js", () => ({
|
||||
runTavilyExtract: vi.fn(async (params: unknown) => ({ ok: true, params })),
|
||||
}));
|
||||
|
||||
import { runTavilyExtract } from "./tavily-client.js";
|
||||
import { createTavilyExtractTool } from "./tavily-extract-tool.js";
|
||||
|
||||
function fakeApi(): OpenClawPluginApi {
|
||||
return {
|
||||
config: {},
|
||||
} as OpenClawPluginApi;
|
||||
}
|
||||
|
||||
describe("tavily_extract", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("rejects chunks_per_source without query", async () => {
|
||||
const tool = createTavilyExtractTool(fakeApi());
|
||||
|
||||
await expect(
|
||||
tool.execute("id", {
|
||||
urls: ["https://example.com"],
|
||||
chunks_per_source: 2,
|
||||
}),
|
||||
).rejects.toThrow("tavily_extract requires query when chunks_per_source is set.");
|
||||
|
||||
expect(runTavilyExtract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards query-scoped chunking when query is provided", async () => {
|
||||
const tool = createTavilyExtractTool(fakeApi());
|
||||
|
||||
await tool.execute("id", {
|
||||
urls: ["https://example.com"],
|
||||
query: "pricing",
|
||||
chunks_per_source: 2,
|
||||
});
|
||||
|
||||
expect(runTavilyExtract).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
urls: ["https://example.com"],
|
||||
query: "pricing",
|
||||
chunksPerSource: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
74
extensions/tavily/src/tavily-extract-tool.ts
Normal file
74
extensions/tavily/src/tavily-extract-tool.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { runTavilyExtract } from "./tavily-client.js";
|
||||
|
||||
const TavilyExtractToolSchema = Type.Object(
|
||||
{
|
||||
urls: Type.Array(Type.String(), {
|
||||
description: "One or more URLs to extract content from (max 20).",
|
||||
minItems: 1,
|
||||
maxItems: 20,
|
||||
}),
|
||||
query: Type.Optional(
|
||||
Type.String({
|
||||
description: "Rerank extracted chunks by relevance to this query.",
|
||||
}),
|
||||
),
|
||||
extract_depth: optionalStringEnum(["basic", "advanced"] as const, {
|
||||
description: '"basic" (default) or "advanced" (for JS-heavy pages).',
|
||||
}),
|
||||
chunks_per_source: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Chunks per URL (1-5, requires query).",
|
||||
minimum: 1,
|
||||
maximum: 5,
|
||||
}),
|
||||
),
|
||||
include_images: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Include image URLs in extraction results.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function createTavilyExtractTool(api: OpenClawPluginApi) {
|
||||
return {
|
||||
name: "tavily_extract",
|
||||
label: "Tavily Extract",
|
||||
description:
|
||||
"Extract clean content from one or more URLs using Tavily. Handles JS-rendered pages. Supports query-focused chunking.",
|
||||
parameters: TavilyExtractToolSchema,
|
||||
execute: async (_toolCallId: string, rawParams: Record<string, unknown>) => {
|
||||
const urls = Array.isArray(rawParams.urls)
|
||||
? (rawParams.urls as string[]).filter(Boolean)
|
||||
: [];
|
||||
if (urls.length === 0) {
|
||||
throw new Error("tavily_extract requires at least one URL.");
|
||||
}
|
||||
const query = readStringParam(rawParams, "query") || undefined;
|
||||
const extractDepth = readStringParam(rawParams, "extract_depth") || undefined;
|
||||
const chunksPerSource = readNumberParam(rawParams, "chunks_per_source", {
|
||||
integer: true,
|
||||
});
|
||||
if (chunksPerSource !== undefined && !query) {
|
||||
throw new Error("tavily_extract requires query when chunks_per_source is set.");
|
||||
}
|
||||
const includeImages = rawParams.include_images === true;
|
||||
|
||||
return jsonResult(
|
||||
await runTavilyExtract({
|
||||
cfg: api.config,
|
||||
urls,
|
||||
query,
|
||||
extractDepth,
|
||||
chunksPerSource,
|
||||
includeImages,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
76
extensions/tavily/src/tavily-search-provider.ts
Normal file
76
extensions/tavily/src/tavily-search-provider.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import {
|
||||
enablePluginInConfig,
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { runTavilySearch } from "./tavily-client.js";
|
||||
|
||||
const GenericTavilySearchSchema = Type.Object(
|
||||
{
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-20).",
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
function getScopedCredentialValue(searchConfig?: Record<string, unknown>): unknown {
|
||||
const scoped = searchConfig?.tavily;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
return undefined;
|
||||
}
|
||||
return (scoped as Record<string, unknown>).apiKey;
|
||||
}
|
||||
|
||||
function setScopedCredentialValue(
|
||||
searchConfigTarget: Record<string, unknown>,
|
||||
value: unknown,
|
||||
): void {
|
||||
const scoped = searchConfigTarget.tavily;
|
||||
if (!scoped || typeof scoped !== "object" || Array.isArray(scoped)) {
|
||||
searchConfigTarget.tavily = { apiKey: value };
|
||||
return;
|
||||
}
|
||||
(scoped as Record<string, unknown>).apiKey = value;
|
||||
}
|
||||
|
||||
export function createTavilyWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
id: "tavily",
|
||||
label: "Tavily Search",
|
||||
hint: "Structured results with domain filters and AI answer summaries",
|
||||
envVars: ["TAVILY_API_KEY"],
|
||||
placeholder: "tvly-...",
|
||||
signupUrl: "https://tavily.com/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/tavily",
|
||||
autoDetectOrder: 70,
|
||||
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"],
|
||||
getCredentialValue: getScopedCredentialValue,
|
||||
setCredentialValue: setScopedCredentialValue,
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "tavily", "apiKey", value);
|
||||
},
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config,
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.",
|
||||
parameters: GenericTavilySearchSchema,
|
||||
execute: async (args) =>
|
||||
await runTavilySearch({
|
||||
cfg: ctx.config,
|
||||
query: typeof args.query === "string" ? args.query : "",
|
||||
maxResults: typeof args.count === "number" ? args.count : undefined,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
81
extensions/tavily/src/tavily-search-tool.ts
Normal file
81
extensions/tavily/src/tavily-search-tool.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { runTavilySearch } from "./tavily-client.js";
|
||||
|
||||
const TavilySearchToolSchema = Type.Object(
|
||||
{
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
search_depth: optionalStringEnum(["basic", "advanced"] as const, {
|
||||
description: 'Search depth: "basic" (default, faster) or "advanced" (more thorough).',
|
||||
}),
|
||||
topic: optionalStringEnum(["general", "news", "finance"] as const, {
|
||||
description: 'Search topic: "general" (default), "news", or "finance".',
|
||||
}),
|
||||
max_results: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-20).",
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
}),
|
||||
),
|
||||
include_answer: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "Include an AI-generated answer summary (default: false).",
|
||||
}),
|
||||
),
|
||||
time_range: optionalStringEnum(["day", "week", "month", "year"] as const, {
|
||||
description: "Filter results by recency: 'day', 'week', 'month', or 'year'.",
|
||||
}),
|
||||
include_domains: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Only include results from these domains.",
|
||||
}),
|
||||
),
|
||||
exclude_domains: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Exclude results from these domains.",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export function createTavilySearchTool(api: OpenClawPluginApi) {
|
||||
return {
|
||||
name: "tavily_search",
|
||||
label: "Tavily Search",
|
||||
description:
|
||||
"Search the web using Tavily Search API. Supports search depth, topic filtering, domain filters, time ranges, and AI answer summaries.",
|
||||
parameters: TavilySearchToolSchema,
|
||||
execute: async (_toolCallId: string, rawParams: Record<string, unknown>) => {
|
||||
const query = readStringParam(rawParams, "query", { required: true });
|
||||
const searchDepth = readStringParam(rawParams, "search_depth") || undefined;
|
||||
const topic = readStringParam(rawParams, "topic") || undefined;
|
||||
const maxResults = readNumberParam(rawParams, "max_results", { integer: true });
|
||||
const includeAnswer = rawParams.include_answer === true;
|
||||
const timeRange = readStringParam(rawParams, "time_range") || undefined;
|
||||
const includeDomains = Array.isArray(rawParams.include_domains)
|
||||
? (rawParams.include_domains as string[]).filter(Boolean)
|
||||
: undefined;
|
||||
const excludeDomains = Array.isArray(rawParams.exclude_domains)
|
||||
? (rawParams.exclude_domains as string[]).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return jsonResult(
|
||||
await runTavilySearch({
|
||||
cfg: api.config,
|
||||
query,
|
||||
searchDepth,
|
||||
topic,
|
||||
maxResults,
|
||||
includeAnswer,
|
||||
timeRange,
|
||||
includeDomains: includeDomains?.length ? includeDomains : undefined,
|
||||
excludeDomains: excludeDomains?.length ? excludeDomains : undefined,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@ -519,6 +519,8 @@ importers:
|
||||
|
||||
extensions/synthetic: {}
|
||||
|
||||
extensions/tavily: {}
|
||||
|
||||
extensions/telegram:
|
||||
dependencies:
|
||||
'@grammyjs/runner':
|
||||
|
||||
@ -1,123 +1,35 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import type { PluginWebSearchProviderEntry } from "../../plugins/types.js";
|
||||
import { resolvePluginWebSearchProviders } from "../../plugins/web-search-providers.js";
|
||||
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
|
||||
import { normalizeSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import {
|
||||
resolveWebSearchDefinition,
|
||||
resolveWebSearchProviderId,
|
||||
} from "../../web-search/runtime.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult } from "./common.js";
|
||||
import { SEARCH_CACHE } from "./web-search-provider-common.js";
|
||||
import {
|
||||
resolveSearchConfig,
|
||||
resolveSearchEnabled,
|
||||
type WebSearchConfig,
|
||||
} from "./web-search-provider-config.js";
|
||||
|
||||
function readProviderEnvValue(envVars: string[]): string | undefined {
|
||||
for (const envVar of envVars) {
|
||||
const value = normalizeSecretInput(process.env[envVar]);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasProviderCredential(
|
||||
provider: PluginWebSearchProviderEntry,
|
||||
search: WebSearchConfig | undefined,
|
||||
): boolean {
|
||||
const rawValue = provider.getCredentialValue(search as Record<string, unknown> | undefined);
|
||||
const fromConfig = normalizeSecretInput(
|
||||
normalizeResolvedSecretInputString({
|
||||
value: rawValue,
|
||||
path: provider.credentialPath,
|
||||
}),
|
||||
);
|
||||
return Boolean(fromConfig || readProviderEnvValue(provider.envVars));
|
||||
}
|
||||
|
||||
function resolveSearchProvider(search?: WebSearchConfig): string {
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
const raw =
|
||||
search && "provider" in search && typeof search.provider === "string"
|
||||
? search.provider.trim().toLowerCase()
|
||||
: "";
|
||||
|
||||
if (raw) {
|
||||
const explicit = providers.find((provider) => provider.id === raw);
|
||||
if (explicit) {
|
||||
return explicit.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!raw) {
|
||||
for (const provider of providers) {
|
||||
if (!hasProviderCredential(provider, search)) {
|
||||
continue;
|
||||
}
|
||||
logVerbose(
|
||||
`web_search: no provider configured, auto-detected "${provider.id}" from available API keys`,
|
||||
);
|
||||
return provider.id;
|
||||
}
|
||||
}
|
||||
|
||||
return providers[0]?.id ?? "";
|
||||
}
|
||||
|
||||
export function createWebSearchTool(options?: {
|
||||
config?: OpenClawConfig;
|
||||
sandboxed?: boolean;
|
||||
runtimeWebSearch?: RuntimeWebSearchMetadata;
|
||||
}): AnyAgentTool | null {
|
||||
const search = resolveSearchConfig(options?.config);
|
||||
if (!resolveSearchEnabled({ search, sandboxed: options?.sandboxed })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providers = resolvePluginWebSearchProviders({
|
||||
config: options?.config,
|
||||
bundledAllowlistCompat: true,
|
||||
});
|
||||
if (providers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const providerId =
|
||||
options?.runtimeWebSearch?.selectedProvider ??
|
||||
options?.runtimeWebSearch?.providerConfigured ??
|
||||
resolveSearchProvider(search);
|
||||
const provider =
|
||||
providers.find((entry) => entry.id === providerId) ??
|
||||
providers.find((entry) => entry.id === resolveSearchProvider(search)) ??
|
||||
providers[0];
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const definition = provider.createTool({
|
||||
config: options?.config,
|
||||
searchConfig: search as Record<string, unknown> | undefined,
|
||||
runtimeMetadata: options?.runtimeWebSearch,
|
||||
});
|
||||
if (!definition) {
|
||||
const resolved = resolveWebSearchDefinition(options);
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
label: "Web Search",
|
||||
name: "web_search",
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
execute: async (_toolCallId, args) => jsonResult(await definition.execute(args)),
|
||||
description: resolved.definition.description,
|
||||
parameters: resolved.definition.parameters,
|
||||
execute: async (_toolCallId, args) => jsonResult(await resolved.definition.execute(args)),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
SEARCH_CACHE,
|
||||
resolveSearchProvider,
|
||||
resolveSearchProvider: (
|
||||
search?: NonNullable<NonNullable<OpenClawConfig["tools"]>["web"]>["search"],
|
||||
) => resolveWebSearchProviderId({ search }),
|
||||
};
|
||||
|
||||
@ -48,6 +48,15 @@ function createPerplexityConfig(apiKey: string, enabled?: boolean): OpenClawConf
|
||||
};
|
||||
}
|
||||
|
||||
function pluginWebSearchApiKey(config: OpenClawConfig, pluginId: string): unknown {
|
||||
const entry = (
|
||||
config.plugins?.entries as
|
||||
| Record<string, { config?: { webSearch?: { apiKey?: unknown } } }>
|
||||
| undefined
|
||||
)?.[pluginId];
|
||||
return entry?.config?.webSearch?.apiKey;
|
||||
}
|
||||
|
||||
async function runBlankPerplexityKeyEntry(
|
||||
apiKey: string,
|
||||
enabled?: boolean,
|
||||
@ -88,8 +97,9 @@ describe("setupSearch", () => {
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("pplx-test-key");
|
||||
expect(pluginWebSearchApiKey(result, "perplexity")).toBe("pplx-test-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.plugins?.entries?.perplexity?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets provider and key for brave", async () => {
|
||||
@ -101,7 +111,8 @@ describe("setupSearch", () => {
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.apiKey).toBe("BSA-test-key");
|
||||
expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-test-key");
|
||||
expect(result.plugins?.entries?.brave?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets provider and key for gemini", async () => {
|
||||
@ -113,7 +124,8 @@ describe("setupSearch", () => {
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("gemini");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.gemini?.apiKey).toBe("AIza-test");
|
||||
expect(pluginWebSearchApiKey(result, "google")).toBe("AIza-test");
|
||||
expect(result.plugins?.entries?.google?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets provider and key for firecrawl and enables the plugin", async () => {
|
||||
@ -125,7 +137,7 @@ describe("setupSearch", () => {
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("firecrawl");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.firecrawl?.apiKey).toBe("fc-test-key");
|
||||
expect(pluginWebSearchApiKey(result, "firecrawl")).toBe("fc-test-key");
|
||||
expect(result.plugins?.entries?.firecrawl?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
@ -150,7 +162,21 @@ describe("setupSearch", () => {
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("kimi");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(result.tools?.web?.search?.kimi?.apiKey).toBe("sk-moonshot");
|
||||
expect(pluginWebSearchApiKey(result, "moonshot")).toBe("sk-moonshot");
|
||||
expect(result.plugins?.entries?.moonshot?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("sets provider and key for tavily and enables the plugin", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
selectValue: "tavily",
|
||||
textValue: "tvly-test-key",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(pluginWebSearchApiKey(result, "tavily")).toBe("tvly-test-key");
|
||||
expect(result.plugins?.entries?.tavily?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows missing-key note when no key is provided and no env var", async () => {
|
||||
@ -198,7 +224,7 @@ describe("setupSearch", () => {
|
||||
"stored-pplx-key", // pragma: allowlist secret
|
||||
);
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||
expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -209,11 +235,43 @@ describe("setupSearch", () => {
|
||||
false,
|
||||
);
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toBe("stored-pplx-key");
|
||||
expect(pluginWebSearchApiKey(result, "perplexity")).toBe("stored-pplx-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(false);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quickstart skips key prompt when canonical plugin config key exists", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "tavily",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
tavily: {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "tvly-existing-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const { prompter } = createPrompter({ selectValue: "tavily" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
quickstartDefaults: true,
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect(pluginWebSearchApiKey(result, "tavily")).toBe("tvly-existing-key");
|
||||
expect(result.tools?.web?.search?.enabled).toBe(true);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("quickstart falls through to key prompt when no key and no env var", async () => {
|
||||
const original = process.env.XAI_API_KEY;
|
||||
delete process.env.XAI_API_KEY;
|
||||
@ -268,7 +326,7 @@ describe("setupSearch", () => {
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("perplexity");
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
|
||||
expect(pluginWebSearchApiKey(result, "perplexity")).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "PERPLEXITY_API_KEY", // pragma: allowlist secret
|
||||
@ -299,7 +357,7 @@ describe("setupSearch", () => {
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
});
|
||||
expect(result.tools?.web?.search?.perplexity?.apiKey).toEqual({
|
||||
expect(pluginWebSearchApiKey(result, "perplexity")).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENROUTER_API_KEY", // pragma: allowlist secret
|
||||
@ -326,14 +384,41 @@ describe("setupSearch", () => {
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("brave");
|
||||
expect(result.tools?.web?.search?.apiKey).toEqual({
|
||||
expect(pluginWebSearchApiKey(result, "brave")).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "BRAVE_API_KEY",
|
||||
});
|
||||
expect(result.plugins?.entries?.brave?.enabled).toBe(true);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores env-backed SecretRef when secretInputMode=ref for tavily", async () => {
|
||||
const original = process.env.TAVILY_API_KEY;
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
const cfg: OpenClawConfig = {};
|
||||
try {
|
||||
const { prompter } = createPrompter({ selectValue: "tavily" });
|
||||
const result = await setupSearch(cfg, runtime, prompter, {
|
||||
secretInputMode: "ref", // pragma: allowlist secret
|
||||
});
|
||||
expect(result.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect(pluginWebSearchApiKey(result, "tavily")).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TAVILY_API_KEY",
|
||||
});
|
||||
expect(result.plugins?.entries?.tavily?.enabled).toBe(true);
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
} else {
|
||||
process.env.TAVILY_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("stores plaintext key when secretInputMode is unset", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
const { prompter } = createPrompter({
|
||||
@ -341,12 +426,20 @@ describe("setupSearch", () => {
|
||||
textValue: "BSA-plain",
|
||||
});
|
||||
const result = await setupSearch(cfg, runtime, prompter);
|
||||
expect(result.tools?.web?.search?.apiKey).toBe("BSA-plain");
|
||||
expect(pluginWebSearchApiKey(result, "brave")).toBe("BSA-plain");
|
||||
});
|
||||
|
||||
it("exports all 6 providers in SEARCH_PROVIDER_OPTIONS", () => {
|
||||
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(6);
|
||||
it("exports all 7 providers in SEARCH_PROVIDER_OPTIONS", () => {
|
||||
expect(SEARCH_PROVIDER_OPTIONS).toHaveLength(7);
|
||||
const values = SEARCH_PROVIDER_OPTIONS.map((e) => e.value);
|
||||
expect(values).toEqual(["brave", "gemini", "grok", "kimi", "perplexity", "firecrawl"]);
|
||||
expect(values).toEqual([
|
||||
"brave",
|
||||
"gemini",
|
||||
"grok",
|
||||
"kimi",
|
||||
"perplexity",
|
||||
"firecrawl",
|
||||
"tavily",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -53,7 +53,10 @@ function rawKeyValue(config: OpenClawConfig, provider: SearchProvider): unknown
|
||||
config,
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((candidate) => candidate.id === provider);
|
||||
return entry?.getCredentialValue(search as Record<string, unknown> | undefined);
|
||||
return (
|
||||
entry?.getConfiguredCredentialValue?.(config) ??
|
||||
entry?.getCredentialValue(search as Record<string, unknown> | undefined)
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the plaintext key string, or undefined for SecretRefs/missing. */
|
||||
@ -104,7 +107,7 @@ export function applySearchKey(
|
||||
bundledAllowlistCompat: true,
|
||||
}).find((candidate) => candidate.id === provider);
|
||||
const search: MutableSearchConfig = { ...config.tools?.web?.search, provider, enabled: true };
|
||||
if (providerEntry) {
|
||||
if (providerEntry && !providerEntry.setConfiguredCredentialValue) {
|
||||
providerEntry.setCredentialValue(search, key);
|
||||
}
|
||||
const nextBase: OpenClawConfig = {
|
||||
@ -114,7 +117,9 @@ export function applySearchKey(
|
||||
web: { ...config.tools?.web, search },
|
||||
},
|
||||
};
|
||||
return providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
|
||||
const next = providerEntry?.applySelectionConfig?.(nextBase) ?? nextBase;
|
||||
providerEntry?.setConfiguredCredentialValue?.(next, key);
|
||||
return next;
|
||||
}
|
||||
|
||||
function applyProviderOnly(config: OpenClawConfig, provider: SearchProvider): OpenClawConfig {
|
||||
|
||||
@ -59,6 +59,13 @@ vi.mock("../plugins/web-search-providers.js", () => {
|
||||
getCredentialValue: getScoped("perplexity"),
|
||||
getConfiguredCredentialValue: getConfigured("perplexity"),
|
||||
},
|
||||
{
|
||||
id: "tavily",
|
||||
envVars: ["TAVILY_API_KEY"],
|
||||
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
getCredentialValue: getScoped("tavily"),
|
||||
getConfiguredCredentialValue: getConfigured("tavily"),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
@ -66,6 +73,17 @@ vi.mock("../plugins/web-search-providers.js", () => {
|
||||
const { __testing } = await import("../agents/tools/web-search.js");
|
||||
const { resolveSearchProvider } = __testing;
|
||||
|
||||
function pluginWebSearchApiKey(
|
||||
config: Record<string, unknown> | undefined,
|
||||
pluginId: string,
|
||||
): unknown {
|
||||
return (
|
||||
config?.plugins as
|
||||
| { entries?: Record<string, { config?: { webSearch?: { apiKey?: unknown } } }> }
|
||||
| undefined
|
||||
)?.entries?.[pluginId]?.config?.webSearch?.apiKey;
|
||||
}
|
||||
|
||||
describe("web search provider config", () => {
|
||||
it("accepts perplexity provider and config", () => {
|
||||
const res = validateConfigObjectWithPlugins(
|
||||
@ -113,6 +131,50 @@ describe("web search provider config", () => {
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts tavily provider config on the plugin-owned path", () => {
|
||||
const res = validateConfigObjectWithPlugins(
|
||||
buildWebSearchProviderConfig({
|
||||
enabled: true,
|
||||
provider: "tavily",
|
||||
providerConfig: {
|
||||
apiKey: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "TAVILY_API_KEY",
|
||||
},
|
||||
baseUrl: "https://api.tavily.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("does not migrate the nonexistent legacy Tavily scoped config", () => {
|
||||
const res = validateConfigObjectWithPlugins({
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "tavily",
|
||||
tavily: {
|
||||
apiKey: "tvly-test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.config.tools?.web?.search?.provider).toBe("tavily");
|
||||
expect((res.config.tools?.web?.search as Record<string, unknown> | undefined)?.tavily).toBe(
|
||||
undefined,
|
||||
);
|
||||
expect(pluginWebSearchApiKey(res.config as Record<string, unknown>, "tavily")).toBe(undefined);
|
||||
});
|
||||
|
||||
it("accepts gemini provider with no extra config", () => {
|
||||
const res = validateConfigObjectWithPlugins(
|
||||
buildWebSearchProviderConfig({
|
||||
@ -161,6 +223,7 @@ describe("web search provider auto-detection", () => {
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
delete process.env.PERPLEXITY_API_KEY;
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
delete process.env.XAI_API_KEY;
|
||||
delete process.env.KIMI_API_KEY;
|
||||
delete process.env.MOONSHOT_API_KEY;
|
||||
@ -185,6 +248,11 @@ describe("web search provider auto-detection", () => {
|
||||
expect(resolveSearchProvider({})).toBe("gemini");
|
||||
});
|
||||
|
||||
it("auto-detects tavily when only TAVILY_API_KEY is set", () => {
|
||||
process.env.TAVILY_API_KEY = "tvly-test-key"; // pragma: allowlist secret
|
||||
expect(resolveSearchProvider({})).toBe("tavily");
|
||||
});
|
||||
|
||||
it("auto-detects firecrawl when only FIRECRAWL_API_KEY is set", () => {
|
||||
process.env.FIRECRAWL_API_KEY = "fc-test-key"; // pragma: allowlist secret
|
||||
expect(resolveSearchProvider({})).toBe("firecrawl");
|
||||
|
||||
@ -98,6 +98,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"agent.wait",
|
||||
"wake",
|
||||
"talk.mode",
|
||||
"talk.speak",
|
||||
"tts.enable",
|
||||
"tts.disable",
|
||||
"tts.convert",
|
||||
|
||||
@ -48,6 +48,10 @@ import {
|
||||
TalkConfigParamsSchema,
|
||||
type TalkConfigResult,
|
||||
TalkConfigResultSchema,
|
||||
type TalkSpeakParams,
|
||||
TalkSpeakParamsSchema,
|
||||
type TalkSpeakResult,
|
||||
TalkSpeakResultSchema,
|
||||
type ChannelsStatusParams,
|
||||
ChannelsStatusParamsSchema,
|
||||
type ChannelsStatusResult,
|
||||
@ -375,6 +379,8 @@ export const validateWizardStatusParams = ajv.compile<WizardStatusParams>(Wizard
|
||||
export const validateTalkModeParams = ajv.compile<TalkModeParams>(TalkModeParamsSchema);
|
||||
export const validateTalkConfigParams = ajv.compile<TalkConfigParams>(TalkConfigParamsSchema);
|
||||
export const validateTalkConfigResult = ajv.compile<TalkConfigResult>(TalkConfigResultSchema);
|
||||
export const validateTalkSpeakParams = ajv.compile<TalkSpeakParams>(TalkSpeakParamsSchema);
|
||||
export const validateTalkSpeakResult = ajv.compile<TalkSpeakResult>(TalkSpeakResultSchema);
|
||||
export const validateChannelsStatusParams = ajv.compile<ChannelsStatusParams>(
|
||||
ChannelsStatusParamsSchema,
|
||||
);
|
||||
@ -540,6 +546,8 @@ export {
|
||||
WizardStatusResultSchema,
|
||||
TalkConfigParamsSchema,
|
||||
TalkConfigResultSchema,
|
||||
TalkSpeakParamsSchema,
|
||||
TalkSpeakResultSchema,
|
||||
ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResultSchema,
|
||||
ChannelsLogoutParamsSchema,
|
||||
@ -629,6 +637,8 @@ export type {
|
||||
WizardStatusResult,
|
||||
TalkConfigParams,
|
||||
TalkConfigResult,
|
||||
TalkSpeakParams,
|
||||
TalkSpeakResult,
|
||||
TalkModeParams,
|
||||
ChannelsStatusParams,
|
||||
ChannelsStatusResult,
|
||||
|
||||
@ -16,6 +16,24 @@ export const TalkConfigParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TalkSpeakParamsSchema = Type.Object(
|
||||
{
|
||||
text: NonEmptyString,
|
||||
voiceId: Type.Optional(Type.String()),
|
||||
modelId: Type.Optional(Type.String()),
|
||||
outputFormat: Type.Optional(Type.String()),
|
||||
speed: Type.Optional(Type.Number()),
|
||||
stability: Type.Optional(Type.Number()),
|
||||
similarity: Type.Optional(Type.Number()),
|
||||
style: Type.Optional(Type.Number()),
|
||||
speakerBoost: Type.Optional(Type.Boolean()),
|
||||
seed: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||
normalize: Type.Optional(Type.String()),
|
||||
language: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const talkProviderFieldSchemas = {
|
||||
voiceId: Type.Optional(Type.String()),
|
||||
voiceAliases: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||
@ -85,6 +103,18 @@ export const TalkConfigResultSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TalkSpeakResultSchema = Type.Object(
|
||||
{
|
||||
audioBase64: NonEmptyString,
|
||||
provider: NonEmptyString,
|
||||
outputFormat: Type.Optional(Type.String()),
|
||||
voiceCompatible: Type.Optional(Type.Boolean()),
|
||||
mimeType: Type.Optional(Type.String()),
|
||||
fileExtension: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChannelsStatusParamsSchema = Type.Object(
|
||||
{
|
||||
probe: Type.Optional(Type.Boolean()),
|
||||
|
||||
@ -44,6 +44,8 @@ import {
|
||||
ChannelsLogoutParamsSchema,
|
||||
TalkConfigParamsSchema,
|
||||
TalkConfigResultSchema,
|
||||
TalkSpeakParamsSchema,
|
||||
TalkSpeakResultSchema,
|
||||
ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResultSchema,
|
||||
TalkModeParamsSchema,
|
||||
@ -238,6 +240,8 @@ export const ProtocolSchemas = {
|
||||
TalkModeParams: TalkModeParamsSchema,
|
||||
TalkConfigParams: TalkConfigParamsSchema,
|
||||
TalkConfigResult: TalkConfigResultSchema,
|
||||
TalkSpeakParams: TalkSpeakParamsSchema,
|
||||
TalkSpeakResult: TalkSpeakResultSchema,
|
||||
ChannelsStatusParams: ChannelsStatusParamsSchema,
|
||||
ChannelsStatusResult: ChannelsStatusResultSchema,
|
||||
ChannelsLogoutParams: ChannelsLogoutParamsSchema,
|
||||
|
||||
@ -70,6 +70,8 @@ export type WizardStatusResult = SchemaType<"WizardStatusResult">;
|
||||
export type TalkModeParams = SchemaType<"TalkModeParams">;
|
||||
export type TalkConfigParams = SchemaType<"TalkConfigParams">;
|
||||
export type TalkConfigResult = SchemaType<"TalkConfigResult">;
|
||||
export type TalkSpeakParams = SchemaType<"TalkSpeakParams">;
|
||||
export type TalkSpeakResult = SchemaType<"TalkSpeakResult">;
|
||||
export type ChannelsStatusParams = SchemaType<"ChannelsStatusParams">;
|
||||
export type ChannelsStatusResult = SchemaType<"ChannelsStatusResult">;
|
||||
export type ChannelsLogoutParams = SchemaType<"ChannelsLogoutParams">;
|
||||
|
||||
@ -34,6 +34,7 @@ const BASE_METHODS = [
|
||||
"wizard.cancel",
|
||||
"wizard.status",
|
||||
"talk.config",
|
||||
"talk.speak",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"tools.catalog",
|
||||
|
||||
@ -1,23 +1,281 @@
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { redactConfigObject } from "../../config/redact-snapshot.js";
|
||||
import { buildTalkConfigResponse } from "../../config/talk.js";
|
||||
import { buildTalkConfigResponse, resolveActiveTalkProviderConfig } from "../../config/talk.js";
|
||||
import type { TalkProviderConfig } from "../../config/types.gateway.js";
|
||||
import type { OpenClawConfig, TtsConfig } from "../../config/types.js";
|
||||
import { normalizeSpeechProviderId } from "../../tts/provider-registry.js";
|
||||
import { synthesizeSpeech, type TtsDirectiveOverrides } from "../../tts/tts.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateTalkConfigParams,
|
||||
validateTalkModeParams,
|
||||
validateTalkSpeakParams,
|
||||
} from "../protocol/index.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
const ADMIN_SCOPE = "operator.admin";
|
||||
const TALK_SECRETS_SCOPE = "operator.talk.secrets";
|
||||
type ElevenLabsVoiceSettings = NonNullable<NonNullable<TtsConfig["elevenlabs"]>["voiceSettings"]>;
|
||||
|
||||
function canReadTalkSecrets(client: { connect?: { scopes?: string[] } } | null): boolean {
|
||||
const scopes = Array.isArray(client?.connect?.scopes) ? client.connect.scopes : [];
|
||||
return scopes.includes(ADMIN_SCOPE) || scopes.includes(TALK_SECRETS_SCOPE);
|
||||
}
|
||||
|
||||
function trimString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function optionalBoolean(value: unknown): boolean | undefined {
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function plainObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeTextNormalization(value: unknown): "auto" | "on" | "off" | undefined {
|
||||
const normalized = trimString(value)?.toLowerCase();
|
||||
return normalized === "auto" || normalized === "on" || normalized === "off"
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeAliasKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveTalkVoiceId(
|
||||
providerConfig: TalkProviderConfig,
|
||||
requested: string | undefined,
|
||||
): string | undefined {
|
||||
if (!requested) {
|
||||
return undefined;
|
||||
}
|
||||
const aliases = providerConfig.voiceAliases;
|
||||
if (!aliases) {
|
||||
return requested;
|
||||
}
|
||||
const normalizedRequested = normalizeAliasKey(requested);
|
||||
for (const [alias, voiceId] of Object.entries(aliases)) {
|
||||
if (normalizeAliasKey(alias) === normalizedRequested) {
|
||||
return voiceId;
|
||||
}
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
function readTalkVoiceSettings(
|
||||
providerConfig: TalkProviderConfig,
|
||||
): ElevenLabsVoiceSettings | undefined {
|
||||
const source = plainObject(providerConfig.voiceSettings);
|
||||
if (!source) {
|
||||
return undefined;
|
||||
}
|
||||
const stability = finiteNumber(source.stability);
|
||||
const similarityBoost = finiteNumber(source.similarityBoost);
|
||||
const style = finiteNumber(source.style);
|
||||
const useSpeakerBoost = optionalBoolean(source.useSpeakerBoost);
|
||||
const speed = finiteNumber(source.speed);
|
||||
const voiceSettings = {
|
||||
...(stability == null ? {} : { stability }),
|
||||
...(similarityBoost == null ? {} : { similarityBoost }),
|
||||
...(style == null ? {} : { style }),
|
||||
...(useSpeakerBoost == null ? {} : { useSpeakerBoost }),
|
||||
...(speed == null ? {} : { speed }),
|
||||
};
|
||||
return Object.keys(voiceSettings).length > 0 ? voiceSettings : undefined;
|
||||
}
|
||||
|
||||
function buildTalkTtsConfig(
|
||||
config: OpenClawConfig,
|
||||
):
|
||||
| { cfg: OpenClawConfig; provider: string; providerConfig: TalkProviderConfig }
|
||||
| { error: string } {
|
||||
const resolved = resolveActiveTalkProviderConfig(config.talk);
|
||||
const provider = normalizeSpeechProviderId(resolved?.provider);
|
||||
if (!resolved || !provider) {
|
||||
return { error: "talk.speak unavailable: talk provider not configured" };
|
||||
}
|
||||
|
||||
const baseTts = config.messages?.tts ?? {};
|
||||
const providerConfig = resolved.config;
|
||||
const talkTts: TtsConfig = {
|
||||
...baseTts,
|
||||
auto: "always",
|
||||
provider,
|
||||
};
|
||||
const baseUrl = trimString(providerConfig.baseUrl);
|
||||
const voiceId = trimString(providerConfig.voiceId);
|
||||
const modelId = trimString(providerConfig.modelId);
|
||||
const languageCode = trimString(providerConfig.languageCode);
|
||||
|
||||
if (provider === "elevenlabs") {
|
||||
const seed = finiteNumber(providerConfig.seed);
|
||||
const applyTextNormalization = normalizeTextNormalization(
|
||||
providerConfig.applyTextNormalization,
|
||||
);
|
||||
const voiceSettings = readTalkVoiceSettings(providerConfig);
|
||||
talkTts.elevenlabs = {
|
||||
...baseTts.elevenlabs,
|
||||
...(providerConfig.apiKey === undefined ? {} : { apiKey: providerConfig.apiKey }),
|
||||
...(baseUrl == null ? {} : { baseUrl }),
|
||||
...(voiceId == null ? {} : { voiceId }),
|
||||
...(modelId == null ? {} : { modelId }),
|
||||
...(seed == null ? {} : { seed }),
|
||||
...(applyTextNormalization == null ? {} : { applyTextNormalization }),
|
||||
...(languageCode == null ? {} : { languageCode }),
|
||||
...(voiceSettings == null ? {} : { voiceSettings }),
|
||||
};
|
||||
} else if (provider === "openai") {
|
||||
const speed = finiteNumber(providerConfig.speed);
|
||||
const instructions = trimString(providerConfig.instructions);
|
||||
talkTts.openai = {
|
||||
...baseTts.openai,
|
||||
...(providerConfig.apiKey === undefined ? {} : { apiKey: providerConfig.apiKey }),
|
||||
...(baseUrl == null ? {} : { baseUrl }),
|
||||
...(modelId == null ? {} : { model: modelId }),
|
||||
...(voiceId == null ? {} : { voice: voiceId }),
|
||||
...(speed == null ? {} : { speed }),
|
||||
...(instructions == null ? {} : { instructions }),
|
||||
};
|
||||
} else if (provider === "microsoft") {
|
||||
const outputFormat = trimString(providerConfig.outputFormat);
|
||||
const pitch = trimString(providerConfig.pitch);
|
||||
const rate = trimString(providerConfig.rate);
|
||||
const volume = trimString(providerConfig.volume);
|
||||
const proxy = trimString(providerConfig.proxy);
|
||||
const timeoutMs = finiteNumber(providerConfig.timeoutMs);
|
||||
talkTts.microsoft = {
|
||||
...baseTts.microsoft,
|
||||
enabled: true,
|
||||
...(voiceId == null ? {} : { voice: voiceId }),
|
||||
...(languageCode == null ? {} : { lang: languageCode }),
|
||||
...(outputFormat == null ? {} : { outputFormat }),
|
||||
...(pitch == null ? {} : { pitch }),
|
||||
...(rate == null ? {} : { rate }),
|
||||
...(volume == null ? {} : { volume }),
|
||||
...(proxy == null ? {} : { proxy }),
|
||||
...(timeoutMs == null ? {} : { timeoutMs }),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
providerConfig,
|
||||
cfg: {
|
||||
...config,
|
||||
messages: {
|
||||
...config.messages,
|
||||
tts: talkTts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildTalkSpeakOverrides(
|
||||
provider: string,
|
||||
providerConfig: TalkProviderConfig,
|
||||
params: Record<string, unknown>,
|
||||
): TtsDirectiveOverrides {
|
||||
const voiceId = resolveTalkVoiceId(providerConfig, trimString(params.voiceId));
|
||||
const modelId = trimString(params.modelId);
|
||||
const outputFormat = trimString(params.outputFormat);
|
||||
const speed = finiteNumber(params.speed);
|
||||
const seed = finiteNumber(params.seed);
|
||||
const normalize = normalizeTextNormalization(params.normalize);
|
||||
const language = trimString(params.language)?.toLowerCase();
|
||||
const overrides: TtsDirectiveOverrides = { provider };
|
||||
|
||||
if (provider === "elevenlabs") {
|
||||
const voiceSettings = {
|
||||
...(speed == null ? {} : { speed }),
|
||||
...(finiteNumber(params.stability) == null
|
||||
? {}
|
||||
: { stability: finiteNumber(params.stability) }),
|
||||
...(finiteNumber(params.similarity) == null
|
||||
? {}
|
||||
: { similarityBoost: finiteNumber(params.similarity) }),
|
||||
...(finiteNumber(params.style) == null ? {} : { style: finiteNumber(params.style) }),
|
||||
...(optionalBoolean(params.speakerBoost) == null
|
||||
? {}
|
||||
: { useSpeakerBoost: optionalBoolean(params.speakerBoost) }),
|
||||
};
|
||||
overrides.elevenlabs = {
|
||||
...(voiceId == null ? {} : { voiceId }),
|
||||
...(modelId == null ? {} : { modelId }),
|
||||
...(outputFormat == null ? {} : { outputFormat }),
|
||||
...(seed == null ? {} : { seed }),
|
||||
...(normalize == null ? {} : { applyTextNormalization: normalize }),
|
||||
...(language == null ? {} : { languageCode: language }),
|
||||
...(Object.keys(voiceSettings).length === 0 ? {} : { voiceSettings }),
|
||||
};
|
||||
return overrides;
|
||||
}
|
||||
|
||||
if (provider === "openai") {
|
||||
overrides.openai = {
|
||||
...(voiceId == null ? {} : { voice: voiceId }),
|
||||
...(modelId == null ? {} : { model: modelId }),
|
||||
...(speed == null ? {} : { speed }),
|
||||
};
|
||||
return overrides;
|
||||
}
|
||||
|
||||
if (provider === "microsoft") {
|
||||
overrides.microsoft = {
|
||||
...(voiceId == null ? {} : { voice: voiceId }),
|
||||
...(outputFormat == null ? {} : { outputFormat }),
|
||||
};
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
function inferMimeType(
|
||||
outputFormat: string | undefined,
|
||||
fileExtension: string | undefined,
|
||||
): string | undefined {
|
||||
const normalizedOutput = outputFormat?.trim().toLowerCase();
|
||||
const normalizedExtension = fileExtension?.trim().toLowerCase();
|
||||
if (
|
||||
normalizedOutput === "mp3" ||
|
||||
normalizedOutput?.startsWith("mp3_") ||
|
||||
normalizedOutput?.endsWith("-mp3") ||
|
||||
normalizedExtension === ".mp3"
|
||||
) {
|
||||
return "audio/mpeg";
|
||||
}
|
||||
if (
|
||||
normalizedOutput === "opus" ||
|
||||
normalizedOutput?.startsWith("opus_") ||
|
||||
normalizedExtension === ".opus" ||
|
||||
normalizedExtension === ".ogg"
|
||||
) {
|
||||
return "audio/ogg";
|
||||
}
|
||||
if (normalizedOutput?.endsWith("-wav") || normalizedExtension === ".wav") {
|
||||
return "audio/wav";
|
||||
}
|
||||
if (normalizedOutput?.endsWith("-webm") || normalizedExtension === ".webm") {
|
||||
return "audio/webm";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const talkHandlers: GatewayRequestHandlers = {
|
||||
"talk.config": async ({ params, respond, client }) => {
|
||||
if (!validateTalkConfigParams(params)) {
|
||||
@ -65,6 +323,65 @@ export const talkHandlers: GatewayRequestHandlers = {
|
||||
|
||||
respond(true, { config: configPayload }, undefined);
|
||||
},
|
||||
"talk.speak": async ({ params, respond }) => {
|
||||
if (!validateTalkSpeakParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid talk.speak params: ${formatValidationErrors(validateTalkSpeakParams.errors)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = trimString((params as { text?: unknown }).text);
|
||||
if (!text) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "talk.speak requires text"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const setup = buildTalkTtsConfig(snapshot.config);
|
||||
if ("error" in setup) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, setup.error));
|
||||
return;
|
||||
}
|
||||
|
||||
const overrides = buildTalkSpeakOverrides(setup.provider, setup.providerConfig, params);
|
||||
const result = await synthesizeSpeech({
|
||||
text,
|
||||
cfg: setup.cfg,
|
||||
overrides,
|
||||
disableFallback: true,
|
||||
});
|
||||
if (!result.success || !result.audioBuffer) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, result.error ?? "talk synthesis failed"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
audioBase64: result.audioBuffer.toString("base64"),
|
||||
provider: result.provider ?? setup.provider,
|
||||
outputFormat: result.outputFormat,
|
||||
voiceCompatible: result.voiceCompatible,
|
||||
mimeType: inferMimeType(result.outputFormat, result.fileExtension),
|
||||
fileExtension: result.fileExtension,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
|
||||
}
|
||||
},
|
||||
"talk.mode": ({ params, respond, context, client, isWebchatConnect }) => {
|
||||
if (client && isWebchatConnect(client.connect) && !context.hasConnectedMobileNode()) {
|
||||
respond(
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
signDevicePayload,
|
||||
} from "../infra/device-identity.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||
import { validateTalkConfigResult } from "./protocol/index.js";
|
||||
@ -41,6 +43,13 @@ type TalkConfigPayload = {
|
||||
};
|
||||
};
|
||||
type TalkConfig = NonNullable<NonNullable<TalkConfigPayload["config"]>["talk"]>;
|
||||
type TalkSpeakPayload = {
|
||||
audioBase64?: string;
|
||||
provider?: string;
|
||||
outputFormat?: string;
|
||||
mimeType?: string;
|
||||
fileExtension?: string;
|
||||
};
|
||||
const TALK_CONFIG_DEVICE_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`openclaw-talk-config-device-${process.pid}.json`,
|
||||
@ -95,6 +104,10 @@ async function fetchTalkConfig(
|
||||
return rpcReq<TalkConfigPayload>(ws, "talk.config", params ?? {});
|
||||
}
|
||||
|
||||
async function fetchTalkSpeak(ws: GatewaySocket, params: Record<string, unknown>) {
|
||||
return rpcReq<TalkSpeakPayload>(ws, "talk.speak", params);
|
||||
}
|
||||
|
||||
function expectElevenLabsTalkConfig(
|
||||
talk: TalkConfig | undefined,
|
||||
expected: {
|
||||
@ -236,4 +249,155 @@ describe("gateway talk.config", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("synthesizes talk audio via the active talk provider", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
provider: "openai",
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: "openai-talk-key", // pragma: allowlist secret
|
||||
voiceId: "alloy",
|
||||
modelId: "gpt-4o-mini-tts",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requestInits: RequestInit[] = [];
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (init) {
|
||||
requestInits.push(init);
|
||||
}
|
||||
return new Response(new Uint8Array([1, 2, 3]), { status: 200 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
await withServer(async (ws) => {
|
||||
await connectOperator(ws, ["operator.read", "operator.write"]);
|
||||
const res = await fetchTalkSpeak(ws, {
|
||||
text: "Hello from talk mode.",
|
||||
voiceId: "nova",
|
||||
modelId: "tts-1",
|
||||
speed: 1.25,
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.provider).toBe("openai");
|
||||
expect(res.payload?.outputFormat).toBe("mp3");
|
||||
expect(res.payload?.mimeType).toBe("audio/mpeg");
|
||||
expect(res.payload?.fileExtension).toBe(".mp3");
|
||||
expect(res.payload?.audioBase64).toBe(Buffer.from([1, 2, 3]).toString("base64"));
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
const requestInit = requestInits.find((init) => typeof init.body === "string");
|
||||
expect(requestInit).toBeDefined();
|
||||
const body = JSON.parse(requestInit?.body as string) as Record<string, unknown>;
|
||||
expect(body.model).toBe("tts-1");
|
||||
expect(body.voice).toBe("nova");
|
||||
expect(body.speed).toBe(1.25);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves talk voice aliases case-insensitively and forwards output format", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
provider: "elevenlabs",
|
||||
providers: {
|
||||
elevenlabs: {
|
||||
apiKey: "elevenlabs-talk-key", // pragma: allowlist secret
|
||||
voiceId: "voice-default",
|
||||
voiceAliases: {
|
||||
Clawd: "EXAVITQu4vr4xnSDxMaL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let fetchUrl: string | undefined;
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
fetchUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
return new Response(new Uint8Array([4, 5, 6]), { status: 200 });
|
||||
});
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
try {
|
||||
await withServer(async (ws) => {
|
||||
await connectOperator(ws, ["operator.read", "operator.write"]);
|
||||
const res = await fetchTalkSpeak(ws, {
|
||||
text: "Hello from talk mode.",
|
||||
voiceId: "clawd",
|
||||
outputFormat: "pcm_44100",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.provider).toBe("elevenlabs");
|
||||
expect(res.payload?.outputFormat).toBe("pcm_44100");
|
||||
expect(res.payload?.audioBase64).toBe(Buffer.from([4, 5, 6]).toString("base64"));
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
expect(fetchUrl).toContain("/v1/text-to-speech/EXAVITQu4vr4xnSDxMaL");
|
||||
expect(fetchUrl).toContain("output_format=pcm_44100");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("allows extension speech providers through talk.speak", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
talk: {
|
||||
provider: "acme",
|
||||
providers: {
|
||||
acme: {
|
||||
voiceId: "plugin-voice",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const previousRegistry = getActivePluginRegistry() ?? createEmptyPluginRegistry();
|
||||
setActivePluginRegistry({
|
||||
...createEmptyPluginRegistry(),
|
||||
speechProviders: [
|
||||
{
|
||||
pluginId: "acme-plugin",
|
||||
source: "test",
|
||||
provider: {
|
||||
id: "acme",
|
||||
label: "Acme Speech",
|
||||
isConfigured: () => true,
|
||||
synthesize: async () => ({
|
||||
audioBuffer: Buffer.from([7, 8, 9]),
|
||||
outputFormat: "mp3",
|
||||
fileExtension: ".mp3",
|
||||
voiceCompatible: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(async (ws) => {
|
||||
await connectOperator(ws, ["operator.read", "operator.write"]);
|
||||
const res = await fetchTalkSpeak(ws, {
|
||||
text: "Hello from plugin talk mode.",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.payload?.provider).toBe("acme");
|
||||
expect(res.payload?.audioBase64).toBe(Buffer.from([7, 8, 9]).toString("base64"));
|
||||
});
|
||||
} finally {
|
||||
setActivePluginRegistry(previousRegistry);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
|
||||
export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
|
||||
anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
brave: ["BRAVE_API_KEY"],
|
||||
byteplus: ["BYTEPLUS_API_KEY"],
|
||||
chutes: ["CHUTES_API_KEY", "CHUTES_OAUTH_TOKEN"],
|
||||
"cloudflare-ai-gateway": ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
|
||||
fal: ["FAL_KEY"],
|
||||
firecrawl: ["FIRECRAWL_API_KEY"],
|
||||
"github-copilot": ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"],
|
||||
google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
|
||||
huggingface: ["HUGGINGFACE_HUB_TOKEN", "HF_TOKEN"],
|
||||
@ -23,10 +25,12 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = {
|
||||
opencode: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
|
||||
"opencode-go": ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
|
||||
openrouter: ["OPENROUTER_API_KEY"],
|
||||
perplexity: ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"],
|
||||
qianfan: ["QIANFAN_API_KEY"],
|
||||
"qwen-portal": ["QWEN_OAUTH_TOKEN", "QWEN_PORTAL_API_KEY"],
|
||||
sglang: ["SGLANG_API_KEY"],
|
||||
synthetic: ["SYNTHETIC_API_KEY"],
|
||||
tavily: ["TAVILY_API_KEY"],
|
||||
together: ["TOGETHER_API_KEY"],
|
||||
venice: ["VENICE_API_KEY"],
|
||||
"vercel-ai-gateway": ["AI_GATEWAY_API_KEY"],
|
||||
|
||||
@ -31,15 +31,22 @@ describe("bundled provider auth env vars", () => {
|
||||
});
|
||||
|
||||
it("reads bundled provider auth env vars from plugin manifests", () => {
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.brave).toEqual(["BRAVE_API_KEY"]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.firecrawl).toEqual(["FIRECRAWL_API_KEY"]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["github-copilot"]).toEqual([
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.perplexity).toEqual([
|
||||
"PERPLEXITY_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["qwen-portal"]).toEqual([
|
||||
"QWEN_OAUTH_TOKEN",
|
||||
"QWEN_PORTAL_API_KEY",
|
||||
]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.tavily).toEqual(["TAVILY_API_KEY"]);
|
||||
expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES["minimax-portal"]).toEqual([
|
||||
"MINIMAX_OAUTH_TOKEN",
|
||||
"MINIMAX_API_KEY",
|
||||
|
||||
@ -71,6 +71,7 @@ describe("bundled web search metadata", () => {
|
||||
"google",
|
||||
"moonshot",
|
||||
"perplexity",
|
||||
"tavily",
|
||||
"xai",
|
||||
]);
|
||||
});
|
||||
|
||||
@ -191,6 +191,21 @@ const BUNDLED_WEB_SEARCH_PROVIDER_DESCRIPTORS = [
|
||||
credentialScope: { kind: "scoped", key: "firecrawl" },
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config,
|
||||
},
|
||||
{
|
||||
pluginId: "tavily",
|
||||
id: "tavily",
|
||||
label: "Tavily Search",
|
||||
hint: "Structured results with domain filters and AI answer summaries",
|
||||
envVars: ["TAVILY_API_KEY"],
|
||||
placeholder: "tvly-...",
|
||||
signupUrl: "https://tavily.com/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/tavily",
|
||||
autoDetectOrder: 70,
|
||||
credentialPath: "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"],
|
||||
credentialScope: { kind: "scoped", key: "tavily" },
|
||||
applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config,
|
||||
},
|
||||
] as const satisfies ReadonlyArray<BundledWebSearchProviderDescriptor>;
|
||||
|
||||
export const BUNDLED_WEB_SEARCH_PLUGIN_IDS = [
|
||||
|
||||
@ -146,6 +146,7 @@ describe("plugin contract registry", () => {
|
||||
expect(findWebSearchIdsForPlugin("google")).toEqual(["gemini"]);
|
||||
expect(findWebSearchIdsForPlugin("moonshot")).toEqual(["kimi"]);
|
||||
expect(findWebSearchIdsForPlugin("perplexity")).toEqual(["perplexity"]);
|
||||
expect(findWebSearchIdsForPlugin("tavily")).toEqual(["tavily"]);
|
||||
expect(findWebSearchIdsForPlugin("xai")).toEqual(["grok"]);
|
||||
});
|
||||
|
||||
@ -183,6 +184,14 @@ describe("plugin contract registry", () => {
|
||||
webSearchProviderIds: ["firecrawl"],
|
||||
toolNames: ["firecrawl_search", "firecrawl_scrape"],
|
||||
});
|
||||
expect(findRegistrationForPlugin("tavily")).toMatchObject({
|
||||
providerIds: [],
|
||||
speechProviderIds: [],
|
||||
mediaUnderstandingProviderIds: [],
|
||||
imageGenerationProviderIds: [],
|
||||
webSearchProviderIds: ["tavily"],
|
||||
toolNames: ["tavily_search", "tavily_extract"],
|
||||
});
|
||||
});
|
||||
|
||||
it("tracks speech registrations on bundled provider plugins", () => {
|
||||
|
||||
@ -29,6 +29,7 @@ import qianfanPlugin from "../../../extensions/qianfan/index.js";
|
||||
import qwenPortalAuthPlugin from "../../../extensions/qwen-portal-auth/index.js";
|
||||
import sglangPlugin from "../../../extensions/sglang/index.js";
|
||||
import syntheticPlugin from "../../../extensions/synthetic/index.js";
|
||||
import tavilyPlugin from "../../../extensions/tavily/index.js";
|
||||
import togetherPlugin from "../../../extensions/together/index.js";
|
||||
import venicePlugin from "../../../extensions/venice/index.js";
|
||||
import vercelAiGatewayPlugin from "../../../extensions/vercel-ai-gateway/index.js";
|
||||
@ -84,9 +85,9 @@ const bundledWebSearchPlugins: Array<RegistrablePlugin & { credentialValue: unkn
|
||||
{ ...googlePlugin, credentialValue: "AIza-test" },
|
||||
{ ...moonshotPlugin, credentialValue: "sk-test" },
|
||||
{ ...perplexityPlugin, credentialValue: "pplx-test" },
|
||||
{ ...tavilyPlugin, credentialValue: "tvly-test" },
|
||||
{ ...xaiPlugin, credentialValue: "xai-test" },
|
||||
];
|
||||
|
||||
const bundledSpeechPlugins: RegistrablePlugin[] = [elevenLabsPlugin, microsoftPlugin, openAIPlugin];
|
||||
|
||||
const bundledMediaUnderstandingPlugins: RegistrablePlugin[] = [
|
||||
|
||||
@ -15,6 +15,7 @@ const BUNDLED_WEB_SEARCH_PROVIDERS = [
|
||||
{ pluginId: "moonshot", id: "kimi", order: 40 },
|
||||
{ pluginId: "perplexity", id: "perplexity", order: 50 },
|
||||
{ pluginId: "firecrawl", id: "firecrawl", order: 60 },
|
||||
{ pluginId: "tavily", id: "tavily", order: 70 },
|
||||
] as const;
|
||||
|
||||
const { loadOpenClawPluginsMock } = vi.hoisted(() => ({
|
||||
@ -96,6 +97,7 @@ describe("resolvePluginWebSearchProviders", () => {
|
||||
"moonshot:kimi",
|
||||
"perplexity:perplexity",
|
||||
"firecrawl:firecrawl",
|
||||
"tavily:tavily",
|
||||
]);
|
||||
expect(providers.map((provider) => provider.credentialPath)).toEqual([
|
||||
"plugins.entries.brave.config.webSearch.apiKey",
|
||||
@ -104,6 +106,7 @@ describe("resolvePluginWebSearchProviders", () => {
|
||||
"plugins.entries.moonshot.config.webSearch.apiKey",
|
||||
"plugins.entries.perplexity.config.webSearch.apiKey",
|
||||
"plugins.entries.firecrawl.config.webSearch.apiKey",
|
||||
"plugins.entries.tavily.config.webSearch.apiKey",
|
||||
]);
|
||||
expect(providers.find((provider) => provider.id === "firecrawl")?.applySelectionConfig).toEqual(
|
||||
expect.any(Function),
|
||||
@ -130,6 +133,7 @@ describe("resolvePluginWebSearchProviders", () => {
|
||||
"moonshot",
|
||||
"perplexity",
|
||||
"firecrawl",
|
||||
"tavily",
|
||||
]);
|
||||
});
|
||||
|
||||
@ -183,6 +187,7 @@ describe("resolvePluginWebSearchProviders", () => {
|
||||
"moonshot:kimi",
|
||||
"perplexity:perplexity",
|
||||
"firecrawl:firecrawl",
|
||||
"tavily:tavily",
|
||||
]);
|
||||
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@ -8,10 +8,28 @@ import {
|
||||
describe("provider env vars", () => {
|
||||
it("keeps the auth scrub list broader than the global secret env list", () => {
|
||||
expect(listKnownProviderAuthEnvVarNames()).toEqual(
|
||||
expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]),
|
||||
expect.arrayContaining([
|
||||
"GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"BRAVE_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"PERPLEXITY_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
]),
|
||||
);
|
||||
expect(listKnownSecretEnvVarNames()).toEqual(
|
||||
expect.arrayContaining(["GITHUB_TOKEN", "GH_TOKEN", "ANTHROPIC_OAUTH_TOKEN"]),
|
||||
expect.arrayContaining([
|
||||
"GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"ANTHROPIC_OAUTH_TOKEN",
|
||||
"BRAVE_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"PERPLEXITY_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
]),
|
||||
);
|
||||
expect(listKnownProviderAuthEnvVarNames()).toEqual(
|
||||
expect.arrayContaining(["MINIMAX_CODE_PLAN_KEY"]),
|
||||
|
||||
@ -80,6 +80,7 @@ function buildTestWebSearchProviders(): PluginWebSearchProviderEntry[] {
|
||||
createTestProvider({ id: "kimi", pluginId: "moonshot", order: 40 }),
|
||||
createTestProvider({ id: "perplexity", pluginId: "perplexity", order: 50 }),
|
||||
createTestProvider({ id: "firecrawl", pluginId: "firecrawl", order: 60 }),
|
||||
createTestProvider({ id: "tavily", pluginId: "tavily", order: 70 }),
|
||||
];
|
||||
}
|
||||
|
||||
@ -194,6 +195,9 @@ function buildConfigForOpenClawTarget(entry: SecretRegistryEntry, envId: string)
|
||||
if (entry.id === "plugins.entries.firecrawl.config.webSearch.apiKey") {
|
||||
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "firecrawl");
|
||||
}
|
||||
if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") {
|
||||
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily");
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@ -843,6 +843,17 @@ const SECRET_TARGET_REGISTRY: SecretTargetRegistryEntry[] = [
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
targetType: "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "plugins.entries.tavily.config.webSearch.apiKey",
|
||||
secretShape: SECRET_INPUT_SHAPE,
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
];
|
||||
|
||||
export { SECRET_TARGET_REGISTRY };
|
||||
|
||||
@ -72,7 +72,9 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
|
||||
if (!apiKey) {
|
||||
throw new Error("ElevenLabs API key missing");
|
||||
}
|
||||
const outputFormat = req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128";
|
||||
const outputFormat =
|
||||
req.overrides?.elevenlabs?.outputFormat ??
|
||||
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
|
||||
const audioBuffer = await elevenLabsTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
|
||||
@ -83,7 +83,7 @@ export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin {
|
||||
const tempRoot = resolvePreferredOpenClawTmpDir();
|
||||
mkdirSync(tempRoot, { recursive: true, mode: 0o700 });
|
||||
const tempDir = mkdtempSync(path.join(tempRoot, "tts-microsoft-"));
|
||||
let outputFormat = req.config.edge.outputFormat;
|
||||
let outputFormat = req.overrides?.microsoft?.outputFormat ?? req.config.edge.outputFormat;
|
||||
const fallbackOutputFormat =
|
||||
outputFormat !== DEFAULT_EDGE_OUTPUT_FORMAT ? DEFAULT_EDGE_OUTPUT_FORMAT : undefined;
|
||||
|
||||
@ -96,6 +96,7 @@ export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin {
|
||||
outputPath,
|
||||
config: {
|
||||
...req.config.edge,
|
||||
voice: req.overrides?.microsoft?.voice ?? req.config.edge.voice,
|
||||
outputFormat: format,
|
||||
},
|
||||
timeoutMs: req.config.timeoutMs,
|
||||
|
||||
@ -21,7 +21,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
|
||||
baseUrl: req.config.openai.baseUrl,
|
||||
model: req.overrides?.openai?.model ?? req.config.openai.model,
|
||||
voice: req.overrides?.openai?.voice ?? req.config.openai.voice,
|
||||
speed: req.config.openai.speed,
|
||||
speed: req.overrides?.openai?.speed ?? req.config.openai.speed,
|
||||
instructions: req.config.openai.instructions,
|
||||
responseFormat,
|
||||
timeoutMs: req.config.timeoutMs,
|
||||
|
||||
@ -162,15 +162,21 @@ export type TtsDirectiveOverrides = {
|
||||
openai?: {
|
||||
voice?: string;
|
||||
model?: string;
|
||||
speed?: number;
|
||||
};
|
||||
elevenlabs?: {
|
||||
voiceId?: string;
|
||||
modelId?: string;
|
||||
outputFormat?: string;
|
||||
seed?: number;
|
||||
applyTextNormalization?: "auto" | "on" | "off";
|
||||
languageCode?: string;
|
||||
voiceSettings?: Partial<ResolvedTtsConfig["elevenlabs"]["voiceSettings"]>;
|
||||
};
|
||||
microsoft?: {
|
||||
voice?: string;
|
||||
outputFormat?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TtsDirectiveParseResult = {
|
||||
@ -191,6 +197,17 @@ export type TtsResult = {
|
||||
voiceCompatible?: boolean;
|
||||
};
|
||||
|
||||
export type TtsSynthesisResult = {
|
||||
success: boolean;
|
||||
audioBuffer?: Buffer;
|
||||
error?: string;
|
||||
latencyMs?: number;
|
||||
provider?: string;
|
||||
outputFormat?: string;
|
||||
voiceCompatible?: boolean;
|
||||
fileExtension?: string;
|
||||
};
|
||||
|
||||
export type TtsTelephonyResult = {
|
||||
success: boolean;
|
||||
audioBuffer?: Buffer;
|
||||
@ -601,6 +618,7 @@ function resolveTtsRequestSetup(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prefsPath?: string;
|
||||
providerOverride?: TtsProvider;
|
||||
disableFallback?: boolean;
|
||||
}):
|
||||
| {
|
||||
config: ResolvedTtsConfig;
|
||||
@ -621,7 +639,7 @@ function resolveTtsRequestSetup(params: {
|
||||
const provider = normalizeSpeechProviderId(params.providerOverride) ?? userProvider;
|
||||
return {
|
||||
config,
|
||||
providers: resolveTtsProviderOrder(provider, params.cfg),
|
||||
providers: params.disableFallback ? [provider] : resolveTtsProviderOrder(provider, params.cfg),
|
||||
};
|
||||
}
|
||||
|
||||
@ -631,12 +649,44 @@ export async function textToSpeech(params: {
|
||||
prefsPath?: string;
|
||||
channel?: string;
|
||||
overrides?: TtsDirectiveOverrides;
|
||||
disableFallback?: boolean;
|
||||
}): Promise<TtsResult> {
|
||||
const synthesis = await synthesizeSpeech(params);
|
||||
if (!synthesis.success || !synthesis.audioBuffer || !synthesis.fileExtension) {
|
||||
return buildTtsFailureResult([synthesis.error ?? "TTS conversion failed"]);
|
||||
}
|
||||
|
||||
const tempRoot = resolvePreferredOpenClawTmpDir();
|
||||
mkdirSync(tempRoot, { recursive: true, mode: 0o700 });
|
||||
const tempDir = mkdtempSync(path.join(tempRoot, "tts-"));
|
||||
const audioPath = path.join(tempDir, `voice-${Date.now()}${synthesis.fileExtension}`);
|
||||
writeFileSync(audioPath, synthesis.audioBuffer);
|
||||
scheduleCleanup(tempDir);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioPath,
|
||||
latencyMs: synthesis.latencyMs,
|
||||
provider: synthesis.provider,
|
||||
outputFormat: synthesis.outputFormat,
|
||||
voiceCompatible: synthesis.voiceCompatible,
|
||||
};
|
||||
}
|
||||
|
||||
export async function synthesizeSpeech(params: {
|
||||
text: string;
|
||||
cfg: OpenClawConfig;
|
||||
prefsPath?: string;
|
||||
channel?: string;
|
||||
overrides?: TtsDirectiveOverrides;
|
||||
disableFallback?: boolean;
|
||||
}): Promise<TtsSynthesisResult> {
|
||||
const setup = resolveTtsRequestSetup({
|
||||
text: params.text,
|
||||
cfg: params.cfg,
|
||||
prefsPath: params.prefsPath,
|
||||
providerOverride: params.overrides?.provider,
|
||||
disableFallback: params.disableFallback,
|
||||
});
|
||||
if ("error" in setup) {
|
||||
return { success: false, error: setup.error };
|
||||
@ -667,22 +717,14 @@ export async function textToSpeech(params: {
|
||||
target,
|
||||
overrides: params.overrides,
|
||||
});
|
||||
const latencyMs = Date.now() - providerStart;
|
||||
|
||||
const tempRoot = resolvePreferredOpenClawTmpDir();
|
||||
mkdirSync(tempRoot, { recursive: true, mode: 0o700 });
|
||||
const tempDir = mkdtempSync(path.join(tempRoot, "tts-"));
|
||||
const audioPath = path.join(tempDir, `voice-${Date.now()}${synthesis.fileExtension}`);
|
||||
writeFileSync(audioPath, synthesis.audioBuffer);
|
||||
scheduleCleanup(tempDir);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
audioPath,
|
||||
latencyMs,
|
||||
audioBuffer: synthesis.audioBuffer,
|
||||
latencyMs: Date.now() - providerStart,
|
||||
provider,
|
||||
outputFormat: synthesis.outputFormat,
|
||||
voiceCompatible: synthesis.voiceCompatible,
|
||||
fileExtension: synthesis.fileExtension,
|
||||
};
|
||||
} catch (err) {
|
||||
errors.push(formatTtsProviderError(provider, err));
|
||||
|
||||
@ -1,8 +1,15 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { runWebSearch } from "./runtime.js";
|
||||
|
||||
type TestPluginWebSearchConfig = {
|
||||
webSearch?: {
|
||||
apiKey?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
describe("web search runtime", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
@ -44,4 +51,74 @@ describe("web search runtime", () => {
|
||||
result: { query: "hello", ok: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-detects a provider from canonical plugin-owned credentials", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.webSearchProviders.push({
|
||||
pluginId: "custom-search",
|
||||
pluginName: "Custom Search",
|
||||
provider: {
|
||||
id: "custom",
|
||||
label: "Custom Search",
|
||||
hint: "Custom runtime provider",
|
||||
envVars: ["CUSTOM_SEARCH_API_KEY"],
|
||||
placeholder: "custom-...",
|
||||
signupUrl: "https://example.com/signup",
|
||||
credentialPath: "plugins.entries.custom-search.config.webSearch.apiKey",
|
||||
autoDetectOrder: 1,
|
||||
getCredentialValue: () => undefined,
|
||||
setCredentialValue: () => {},
|
||||
getConfiguredCredentialValue: (config) => {
|
||||
const pluginConfig = config?.plugins?.entries?.["custom-search"]?.config as
|
||||
| TestPluginWebSearchConfig
|
||||
| undefined;
|
||||
return pluginConfig?.webSearch?.apiKey;
|
||||
},
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
configTarget.plugins = {
|
||||
...configTarget.plugins,
|
||||
entries: {
|
||||
...configTarget.plugins?.entries,
|
||||
"custom-search": {
|
||||
enabled: true,
|
||||
config: { webSearch: { apiKey: value } },
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
createTool: () => ({
|
||||
description: "custom",
|
||||
parameters: {},
|
||||
execute: async (args) => ({ ...args, ok: true }),
|
||||
}),
|
||||
},
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"custom-search": {
|
||||
enabled: true,
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "custom-config-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
runWebSearch({
|
||||
config,
|
||||
args: { query: "hello" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
provider: "custom",
|
||||
result: { query: "hello", ok: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user