Merge upstream/main and align secrets web-search tests

This commit is contained in:
MaxxxDong 2026-03-20 15:03:00 +08:00
commit 92b262cb4b
60 changed files with 2278 additions and 1951 deletions

4
.github/labeler.yml vendored
View File

@ -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:

View 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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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")
}
}

View File

@ -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

View File

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

View File

@ -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?

View File

@ -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?

View File

@ -1031,6 +1031,7 @@
"tools/exec",
"tools/exec-approvals",
"tools/firecrawl",
"tools/tavily",
"tools/llm-task",
"tools/lobster",
"tools/loop-detection",

View File

@ -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`

View File

@ -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",

View File

@ -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
View 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.

View File

@ -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

View File

@ -1,5 +1,8 @@
{
"id": "brave",
"providerAuthEnvVars": {
"brave": ["BRAVE_API_KEY"]
},
"uiHints": {
"webSearch.apiKey": {
"label": "Brave Search API Key",

View File

@ -1,5 +1,8 @@
{
"id": "firecrawl",
"providerAuthEnvVars": {
"firecrawl": ["FIRECRAWL_API_KEY"]
},
"uiHints": {
"webSearch.apiKey": {
"label": "Firecrawl Search API Key",

View File

@ -1,5 +1,8 @@
{
"id": "perplexity",
"providerAuthEnvVars": {
"perplexity": ["PERPLEXITY_API_KEY", "OPENROUTER_API_KEY"]
},
"uiHints": {
"webSearch.apiKey": {
"label": "Perplexity API Key",

View 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");
});
});

View 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);
},
});

View 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"
}
}
}
}
}
}

View 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"
]
}
}

View 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.

View 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;
}

View 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,
};

View 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,
}),
);
});
});

View 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,
}),
);
},
};
}

View 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,
}),
}),
};
}

View 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
View File

@ -519,6 +519,8 @@ importers:
extensions/synthetic: {}
extensions/tavily: {}
extensions/telegram:
dependencies:
'@grammyjs/runner':

View File

@ -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 }),
};

View File

@ -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",
]);
});
});

View File

@ -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 {

View File

@ -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");

View File

@ -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",

View File

@ -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,

View File

@ -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()),

View File

@ -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,

View File

@ -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">;

View File

@ -34,6 +34,7 @@ const BASE_METHODS = [
"wizard.cancel",
"wizard.status",
"talk.config",
"talk.speak",
"talk.mode",
"models.list",
"tools.catalog",

View File

@ -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(

View File

@ -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);
}
});
});

View File

@ -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"],

View File

@ -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",

View File

@ -71,6 +71,7 @@ describe("bundled web search metadata", () => {
"google",
"moonshot",
"perplexity",
"tavily",
"xai",
]);
});

View File

@ -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 = [

View File

@ -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", () => {

View File

@ -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[] = [

View File

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

View File

@ -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"]),

View File

@ -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;
}

View File

@ -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 };

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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));

View File

@ -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 },
});
});
});