@swarmclawai/swarmclaw 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -1,12 +1,61 @@
1
1
  import { describe, it } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { requestElevenLabsMp3Stream, synthesizeElevenLabsMp3 } from './elevenlabs'
3
+ import { requestElevenLabsMp3Stream, resolveElevenLabsConfig, synthesizeElevenLabsMp3 } from './elevenlabs'
4
+ import { encryptKey, loadSecrets, loadSettings, saveSecrets, saveSettings } from './storage'
4
5
 
5
6
  describe('elevenlabs helpers', () => {
7
+ it('prefers agent override first, then settings default, then env fallback', () => {
8
+ const originalSettings = loadSettings()
9
+ const originalKey = process.env.ELEVENLABS_API_KEY
10
+ const originalVoice = process.env.ELEVENLABS_VOICE
11
+
12
+ try {
13
+ saveSettings({
14
+ ...originalSettings,
15
+ elevenLabsApiKey: 'settings-key',
16
+ elevenLabsVoiceId: 'settings-voice',
17
+ })
18
+ process.env.ELEVENLABS_API_KEY = 'env-key'
19
+ process.env.ELEVENLABS_VOICE = 'env-voice'
20
+
21
+ assert.deepEqual(resolveElevenLabsConfig('agent-voice'), {
22
+ apiKey: 'settings-key',
23
+ voiceId: 'agent-voice',
24
+ })
25
+ assert.deepEqual(resolveElevenLabsConfig(null), {
26
+ apiKey: 'settings-key',
27
+ voiceId: 'settings-voice',
28
+ })
29
+
30
+ saveSettings({
31
+ ...originalSettings,
32
+ elevenLabsApiKey: 'settings-key',
33
+ elevenLabsVoiceId: null,
34
+ })
35
+
36
+ assert.deepEqual(resolveElevenLabsConfig(undefined), {
37
+ apiKey: 'settings-key',
38
+ voiceId: 'env-voice',
39
+ })
40
+ } finally {
41
+ saveSettings(originalSettings)
42
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
43
+ else process.env.ELEVENLABS_API_KEY = originalKey
44
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
45
+ else process.env.ELEVENLABS_VOICE = originalVoice
46
+ }
47
+ })
48
+
6
49
  it('synthesizeElevenLabsMp3 posts TTS request and returns audio bytes', async () => {
7
50
  const originalFetch = global.fetch
51
+ const originalSettings = loadSettings()
8
52
  const originalKey = process.env.ELEVENLABS_API_KEY
9
53
  const originalVoice = process.env.ELEVENLABS_VOICE
54
+ saveSettings({
55
+ ...originalSettings,
56
+ elevenLabsApiKey: null,
57
+ elevenLabsVoiceId: null,
58
+ })
10
59
  process.env.ELEVENLABS_API_KEY = 'test-key'
11
60
  process.env.ELEVENLABS_VOICE = 'voice-123'
12
61
 
@@ -25,6 +74,150 @@ describe('elevenlabs helpers', () => {
25
74
  assert.equal(out.toString('utf8'), 'abc')
26
75
  } finally {
27
76
  global.fetch = originalFetch
77
+ saveSettings(originalSettings)
78
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
79
+ else process.env.ELEVENLABS_API_KEY = originalKey
80
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
81
+ else process.env.ELEVENLABS_VOICE = originalVoice
82
+ }
83
+ })
84
+
85
+ it('synthesizeElevenLabsMp3 retries with the built-in fallback voice when the configured voice is paid-only', async () => {
86
+ const originalFetch = global.fetch
87
+ const originalSettings = loadSettings()
88
+ const originalKey = process.env.ELEVENLABS_API_KEY
89
+ const originalVoice = process.env.ELEVENLABS_VOICE
90
+ saveSettings({
91
+ ...originalSettings,
92
+ elevenLabsApiKey: 'settings-key',
93
+ elevenLabsVoiceId: 'paid-only-voice',
94
+ })
95
+ delete process.env.ELEVENLABS_API_KEY
96
+ delete process.env.ELEVENLABS_VOICE
97
+
98
+ const calls: string[] = []
99
+ global.fetch = (async (input: RequestInfo | URL) => {
100
+ const url = String(input)
101
+ calls.push(url)
102
+ if (url.endsWith('/paid-only-voice')) {
103
+ return new Response(
104
+ '{"detail":{"type":"payment_required","code":"paid_plan_required","message":"Free users cannot use library voices via the API."}}',
105
+ { status: 402, headers: { 'Content-Type': 'application/json' } },
106
+ )
107
+ }
108
+ assert.equal(url, 'https://api.elevenlabs.io/v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb')
109
+ return new Response(Buffer.from('fallback-audio'), { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
110
+ }) as typeof fetch
111
+
112
+ try {
113
+ const out = await synthesizeElevenLabsMp3({ text: 'hello world' })
114
+ assert.equal(out.toString('utf8'), 'fallback-audio')
115
+ assert.deepEqual(calls, [
116
+ 'https://api.elevenlabs.io/v1/text-to-speech/paid-only-voice',
117
+ 'https://api.elevenlabs.io/v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb',
118
+ ])
119
+ } finally {
120
+ global.fetch = originalFetch
121
+ saveSettings(originalSettings)
122
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
123
+ else process.env.ELEVENLABS_API_KEY = originalKey
124
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
125
+ else process.env.ELEVENLABS_VOICE = originalVoice
126
+ }
127
+ })
128
+
129
+ it('synthesizeElevenLabsMp3 retries with the built-in fallback voice when the configured voice id is missing', async () => {
130
+ const originalFetch = global.fetch
131
+ const originalSettings = loadSettings()
132
+ const originalKey = process.env.ELEVENLABS_API_KEY
133
+ const originalVoice = process.env.ELEVENLABS_VOICE
134
+ saveSettings({
135
+ ...originalSettings,
136
+ elevenLabsApiKey: 'settings-key',
137
+ elevenLabsVoiceId: 'missing-voice',
138
+ })
139
+ delete process.env.ELEVENLABS_API_KEY
140
+ delete process.env.ELEVENLABS_VOICE
141
+
142
+ const calls: string[] = []
143
+ global.fetch = (async (input: RequestInfo | URL) => {
144
+ const url = String(input)
145
+ calls.push(url)
146
+ if (url.endsWith('/missing-voice')) {
147
+ return new Response(
148
+ '{"detail":{"type":"not_found","code":"voice_not_found","message":"A voice with voice_id \'missing-voice\' was not found."}}',
149
+ { status: 404, headers: { 'Content-Type': 'application/json' } },
150
+ )
151
+ }
152
+ assert.equal(url, 'https://api.elevenlabs.io/v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb')
153
+ return new Response(Buffer.from('fallback-audio'), { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
154
+ }) as typeof fetch
155
+
156
+ try {
157
+ const out = await synthesizeElevenLabsMp3({ text: 'hello world' })
158
+ assert.equal(out.toString('utf8'), 'fallback-audio')
159
+ assert.deepEqual(calls, [
160
+ 'https://api.elevenlabs.io/v1/text-to-speech/missing-voice',
161
+ 'https://api.elevenlabs.io/v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb',
162
+ ])
163
+ } finally {
164
+ global.fetch = originalFetch
165
+ saveSettings(originalSettings)
166
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
167
+ else process.env.ELEVENLABS_API_KEY = originalKey
168
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
169
+ else process.env.ELEVENLABS_VOICE = originalVoice
170
+ }
171
+ })
172
+
173
+ it('synthesizeElevenLabsMp3 falls back from an invalid settings key to a stored ElevenLabs secret', async () => {
174
+ const originalFetch = global.fetch
175
+ const originalSettings = loadSettings()
176
+ const originalSecrets = loadSecrets()
177
+ const originalKey = process.env.ELEVENLABS_API_KEY
178
+ const originalVoice = process.env.ELEVENLABS_VOICE
179
+ saveSettings({
180
+ ...originalSettings,
181
+ elevenLabsApiKey: 'invalid-settings-key',
182
+ elevenLabsVoiceId: 'voice-123',
183
+ })
184
+ saveSecrets({
185
+ eleven_secret: {
186
+ id: 'eleven_secret',
187
+ name: 'ElevenLabs API Key',
188
+ service: 'custom',
189
+ scope: 'global',
190
+ agentIds: [],
191
+ createdAt: Date.now(),
192
+ updatedAt: Date.now(),
193
+ encryptedValue: encryptKey('secret-fallback-key'),
194
+ },
195
+ })
196
+ delete process.env.ELEVENLABS_API_KEY
197
+ delete process.env.ELEVENLABS_VOICE
198
+
199
+ const calls: string[] = []
200
+ global.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
201
+ const key = String((init?.headers as Record<string, string>)['xi-api-key'] || '')
202
+ calls.push(key)
203
+ if (key === 'invalid-settings-key') {
204
+ return new Response(
205
+ '{"detail":{"status":"invalid_api_key","message":"Invalid API key"}}',
206
+ { status: 401, headers: { 'Content-Type': 'application/json' } },
207
+ )
208
+ }
209
+ assert.equal(key, 'secret-fallback-key')
210
+ return new Response(Buffer.from('secret-audio'), { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
211
+ }) as typeof fetch
212
+
213
+ try {
214
+ const out = await synthesizeElevenLabsMp3({ text: 'hello world' })
215
+ assert.equal(out.toString('utf8'), 'secret-audio')
216
+ assert.deepEqual(calls, ['invalid-settings-key', 'secret-fallback-key'])
217
+ } finally {
218
+ global.fetch = originalFetch
219
+ saveSettings(originalSettings)
220
+ saveSecrets(originalSecrets)
28
221
  if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
29
222
  else process.env.ELEVENLABS_API_KEY = originalKey
30
223
  if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
@@ -34,14 +227,21 @@ describe('elevenlabs helpers', () => {
34
227
 
35
228
  it('requestElevenLabsMp3Stream calls streaming endpoint', async () => {
36
229
  const originalFetch = global.fetch
230
+ const originalSettings = loadSettings()
37
231
  const originalKey = process.env.ELEVENLABS_API_KEY
38
232
  const originalVoice = process.env.ELEVENLABS_VOICE
233
+ saveSettings({
234
+ ...originalSettings,
235
+ elevenLabsApiKey: null,
236
+ elevenLabsVoiceId: null,
237
+ })
39
238
  process.env.ELEVENLABS_API_KEY = 'test-key'
40
239
  process.env.ELEVENLABS_VOICE = 'voice-xyz'
41
240
 
42
241
  global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
43
242
  assert.equal(String(input), 'https://api.elevenlabs.io/v1/text-to-speech/voice-xyz/stream')
44
243
  assert.equal(init?.method, 'POST')
244
+ assert.equal((init?.headers as Record<string, string>)['xi-api-key'], 'test-key')
45
245
  return new Response('stream', { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
46
246
  }) as typeof fetch
47
247
 
@@ -51,6 +251,52 @@ describe('elevenlabs helpers', () => {
51
251
  assert.equal(await res.text(), 'stream')
52
252
  } finally {
53
253
  global.fetch = originalFetch
254
+ saveSettings(originalSettings)
255
+ if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
256
+ else process.env.ELEVENLABS_API_KEY = originalKey
257
+ if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
258
+ else process.env.ELEVENLABS_VOICE = originalVoice
259
+ }
260
+ })
261
+
262
+ it('requestElevenLabsMp3Stream retries with the built-in fallback voice when the configured voice is paid-only', async () => {
263
+ const originalFetch = global.fetch
264
+ const originalSettings = loadSettings()
265
+ const originalKey = process.env.ELEVENLABS_API_KEY
266
+ const originalVoice = process.env.ELEVENLABS_VOICE
267
+ saveSettings({
268
+ ...originalSettings,
269
+ elevenLabsApiKey: 'settings-key',
270
+ elevenLabsVoiceId: 'paid-only-voice',
271
+ })
272
+ delete process.env.ELEVENLABS_API_KEY
273
+ delete process.env.ELEVENLABS_VOICE
274
+
275
+ const calls: string[] = []
276
+ global.fetch = (async (input: RequestInfo | URL) => {
277
+ const url = String(input)
278
+ calls.push(url)
279
+ if (url.endsWith('/paid-only-voice/stream')) {
280
+ return new Response(
281
+ '{"detail":{"type":"payment_required","code":"paid_plan_required","message":"Free users cannot use library voices via the API."}}',
282
+ { status: 402, headers: { 'Content-Type': 'application/json' } },
283
+ )
284
+ }
285
+ assert.equal(url, 'https://api.elevenlabs.io/v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb/stream')
286
+ return new Response('fallback-stream', { status: 200, headers: { 'Content-Type': 'audio/mpeg' } })
287
+ }) as typeof fetch
288
+
289
+ try {
290
+ const res = await requestElevenLabsMp3Stream({ text: 'streaming text' })
291
+ assert.equal(res.status, 200)
292
+ assert.equal(await res.text(), 'fallback-stream')
293
+ assert.deepEqual(calls, [
294
+ 'https://api.elevenlabs.io/v1/text-to-speech/paid-only-voice/stream',
295
+ 'https://api.elevenlabs.io/v1/text-to-speech/JBFqnCBsd6RMkjVDRZzb/stream',
296
+ ])
297
+ } finally {
298
+ global.fetch = originalFetch
299
+ saveSettings(originalSettings)
54
300
  if (originalKey === undefined) delete process.env.ELEVENLABS_API_KEY
55
301
  else process.env.ELEVENLABS_API_KEY = originalKey
56
302
  if (originalVoice === undefined) delete process.env.ELEVENLABS_VOICE
@@ -1,4 +1,4 @@
1
- import { loadSettings } from './storage'
1
+ import { decryptKey, loadSettings, loadSecrets } from './storage'
2
2
 
3
3
  const DEFAULT_VOICE_ID = 'JBFqnCBsd6RMkjVDRZzb'
4
4
  const DEFAULT_MODEL_ID = 'eleven_multilingual_v2'
@@ -12,12 +12,12 @@ export function resolveElevenLabsConfig(voiceId?: string | null): {
12
12
  apiKey: string
13
13
  voiceId: string
14
14
  } {
15
- const settings = loadSettings()
16
- const apiKey = String(settings.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY || '').trim()
17
- if (!apiKey) {
15
+ const apiKeys = resolveElevenLabsApiKeyCandidates()
16
+ if (!apiKeys.length) {
18
17
  throw new Error('No ElevenLabs API key. Set one in Settings > Voice.')
19
18
  }
20
19
 
20
+ const settings = loadSettings()
21
21
  const resolvedVoiceId = String(
22
22
  voiceId
23
23
  || settings.elevenLabsVoiceId
@@ -25,44 +25,161 @@ export function resolveElevenLabsConfig(voiceId?: string | null): {
25
25
  || DEFAULT_VOICE_ID,
26
26
  ).trim()
27
27
 
28
- return { apiKey, voiceId: resolvedVoiceId || DEFAULT_VOICE_ID }
28
+ return { apiKey: apiKeys[0], voiceId: resolvedVoiceId || DEFAULT_VOICE_ID }
29
29
  }
30
30
 
31
- export async function synthesizeElevenLabsMp3(params: {
32
- text: string
33
- voiceId?: string | null
34
- stability?: number
35
- similarityBoost?: number
36
- }): Promise<Buffer> {
37
- const text = params.text.trim()
38
- if (!text) throw new Error('No text provided for ElevenLabs synthesis.')
31
+ function resolveElevenLabsApiKeyCandidates(): string[] {
32
+ const settings = loadSettings()
33
+ const candidates: string[] = []
39
34
 
40
- const { apiKey, voiceId } = resolveElevenLabsConfig(params.voiceId)
41
- const stability = Number.isFinite(params.stability) ? Math.max(0, Math.min(1, Number(params.stability))) : 0.5
42
- const similarityBoost = Number.isFinite(params.similarityBoost) ? Math.max(0, Math.min(1, Number(params.similarityBoost))) : 0.75
35
+ const pushCandidate = (value: unknown) => {
36
+ if (typeof value !== 'string') return
37
+ const trimmed = value.trim()
38
+ if (trimmed) candidates.push(trimmed)
39
+ }
40
+
41
+ pushCandidate(settings.elevenLabsApiKey)
42
+ pushCandidate(process.env.ELEVENLABS_API_KEY)
43
+
44
+ for (const secret of Object.values(loadSecrets()) as Array<Record<string, unknown>>) {
45
+ const label = [
46
+ typeof secret.id === 'string' ? secret.id : '',
47
+ typeof secret.name === 'string' ? secret.name : '',
48
+ typeof secret.service === 'string' ? secret.service : '',
49
+ ].join(' ').toLowerCase()
50
+ if (!label.includes('eleven')) continue
51
+ if (typeof secret.encryptedValue !== 'string' || !secret.encryptedValue) continue
52
+ try {
53
+ pushCandidate(decryptKey(secret.encryptedValue))
54
+ } catch {
55
+ // Ignore undecryptable secrets and continue.
56
+ }
57
+ }
43
58
 
44
- const apiRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
59
+ return [...new Set(candidates)]
60
+ }
61
+
62
+ function shouldRetryWithFallbackVoice(voiceId: string, errBody: string): boolean {
63
+ if (!voiceId || voiceId === DEFAULT_VOICE_ID) return false
64
+ return /paid_plan_required|library voices via the api|voice_not_found|voice with voice_id .* was not found/i.test(errBody)
65
+ }
66
+
67
+ async function postElevenLabsTts(params: {
68
+ apiKey: string
69
+ voiceId: string
70
+ text: string
71
+ stability: number
72
+ similarityBoost: number
73
+ stream?: boolean
74
+ }): Promise<Response> {
75
+ const endpoint = params.stream
76
+ ? `https://api.elevenlabs.io/v1/text-to-speech/${params.voiceId}/stream`
77
+ : `https://api.elevenlabs.io/v1/text-to-speech/${params.voiceId}`
78
+ return fetch(endpoint, {
45
79
  method: 'POST',
46
80
  headers: {
47
- 'xi-api-key': apiKey,
81
+ 'xi-api-key': params.apiKey,
48
82
  'Content-Type': 'application/json',
49
83
  Accept: 'audio/mpeg',
50
84
  },
51
85
  body: JSON.stringify({
52
- text,
86
+ text: params.stream ? params.text.slice(0, 2000) : params.text,
53
87
  model_id: DEFAULT_MODEL_ID,
54
88
  voice_settings: {
55
- stability,
56
- similarity_boost: similarityBoost,
89
+ stability: params.stability,
90
+ similarity_boost: params.similarityBoost,
57
91
  },
92
+ ...(params.stream ? { output_format: 'mp3_22050_32' } : {}),
58
93
  }),
59
94
  })
95
+ }
96
+
97
+ async function requestElevenLabsAudioWithFallback(params: {
98
+ text: string
99
+ voiceId?: string | null
100
+ stability: number
101
+ similarityBoost: number
102
+ stream?: boolean
103
+ }): Promise<Response> {
104
+ const settings = loadSettings()
105
+ const voiceId = String(
106
+ params.voiceId
107
+ || settings.elevenLabsVoiceId
108
+ || process.env.ELEVENLABS_VOICE
109
+ || DEFAULT_VOICE_ID,
110
+ ).trim() || DEFAULT_VOICE_ID
111
+ const apiKeys = resolveElevenLabsApiKeyCandidates()
112
+ if (!apiKeys.length) {
113
+ throw new Error('No ElevenLabs API key. Set one in Settings > Voice.')
114
+ }
115
+
116
+ let lastError = ''
117
+ for (const apiKey of apiKeys) {
118
+ const firstRes = await postElevenLabsTts({
119
+ apiKey,
120
+ voiceId,
121
+ text: params.text,
122
+ stability: params.stability,
123
+ similarityBoost: params.similarityBoost,
124
+ stream: params.stream,
125
+ })
126
+ if (firstRes.ok) return firstRes
127
+
128
+ const firstErr = await firstRes.text().catch(() => '')
129
+ lastError = firstErr || `ElevenLabs request failed (${firstRes.status})`
130
+
131
+ if (shouldRetryWithFallbackVoice(voiceId, firstErr)) {
132
+ const fallbackRes = await postElevenLabsTts({
133
+ apiKey,
134
+ voiceId: DEFAULT_VOICE_ID,
135
+ text: params.text,
136
+ stability: params.stability,
137
+ similarityBoost: params.similarityBoost,
138
+ stream: params.stream,
139
+ })
140
+ if (fallbackRes.ok) return fallbackRes
60
141
 
61
- if (!apiRes.ok) {
62
- const errBody = await apiRes.text().catch(() => '')
63
- throw new Error(errBody || `ElevenLabs request failed (${apiRes.status})`)
142
+ const fallbackErr = await fallbackRes.text().catch(() => '')
143
+ lastError = fallbackErr
144
+ ? `${lastError} | fallback voice failed: ${fallbackErr}`
145
+ : lastError
146
+ if (!shouldTryNextApiKey(lastError)) {
147
+ throw new Error(lastError)
148
+ }
149
+ continue
150
+ }
151
+
152
+ if (!shouldTryNextApiKey(firstErr)) {
153
+ throw new Error(lastError)
154
+ }
64
155
  }
65
156
 
157
+ throw new Error(lastError || 'ElevenLabs request failed')
158
+ }
159
+
160
+ function shouldTryNextApiKey(errBody: string): boolean {
161
+ return /invalid_api_key|missing_permissions|detected_unusual_activity/i.test(errBody)
162
+ }
163
+
164
+ export async function synthesizeElevenLabsMp3(params: {
165
+ text: string
166
+ voiceId?: string | null
167
+ stability?: number
168
+ similarityBoost?: number
169
+ }): Promise<Buffer> {
170
+ const text = params.text.trim()
171
+ if (!text) throw new Error('No text provided for ElevenLabs synthesis.')
172
+
173
+ const stability = Number.isFinite(params.stability) ? Math.max(0, Math.min(1, Number(params.stability))) : 0.5
174
+ const similarityBoost = Number.isFinite(params.similarityBoost) ? Math.max(0, Math.min(1, Number(params.similarityBoost))) : 0.75
175
+
176
+ const apiRes = await requestElevenLabsAudioWithFallback({
177
+ text,
178
+ voiceId: params.voiceId,
179
+ stability,
180
+ similarityBoost,
181
+ })
182
+
66
183
  const audioBuffer = await apiRes.arrayBuffer()
67
184
  return Buffer.from(audioBuffer)
68
185
  }
@@ -74,27 +191,14 @@ export async function requestElevenLabsMp3Stream(params: {
74
191
  const text = params.text.trim()
75
192
  if (!text) throw new Error('No text provided for ElevenLabs stream.')
76
193
 
77
- const { apiKey, voiceId } = resolveElevenLabsConfig(params.voiceId)
78
- const apiRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream`, {
79
- method: 'POST',
80
- headers: {
81
- 'xi-api-key': apiKey,
82
- 'Content-Type': 'application/json',
83
- Accept: 'audio/mpeg',
84
- },
85
- body: JSON.stringify({
86
- text: text.slice(0, 2000),
87
- model_id: DEFAULT_MODEL_ID,
88
- voice_settings: { stability: 0.5, similarity_boost: 0.75 },
89
- output_format: 'mp3_22050_32',
90
- }),
194
+ const apiRes = await requestElevenLabsAudioWithFallback({
195
+ text,
196
+ voiceId: params.voiceId,
197
+ stability: 0.5,
198
+ similarityBoost: 0.75,
199
+ stream: true,
91
200
  })
92
201
 
93
- if (!apiRes.ok) {
94
- const errBody = await apiRes.text().catch(() => '')
95
- throw new Error(errBody || `ElevenLabs streaming request failed (${apiRes.status})`)
96
- }
97
-
98
202
  return apiRes
99
203
  }
100
204