ruby_llm-agents 3.3.0 → 3.5.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 (29) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +49 -1
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +27 -4
  4. data/app/services/ruby_llm/agents/agent_registry.rb +3 -1
  5. data/app/views/ruby_llm/agents/agents/_config_router.html.erb +110 -0
  6. data/app/views/ruby_llm/agents/agents/index.html.erb +6 -0
  7. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -0
  8. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +8 -0
  9. data/lib/ruby_llm/agents/audio/elevenlabs/model_registry.rb +187 -0
  10. data/lib/ruby_llm/agents/audio/speaker.rb +38 -0
  11. data/lib/ruby_llm/agents/audio/speech_client.rb +26 -2
  12. data/lib/ruby_llm/agents/audio/speech_pricing.rb +44 -3
  13. data/lib/ruby_llm/agents/audio/transcriber.rb +26 -15
  14. data/lib/ruby_llm/agents/audio/transcription_pricing.rb +226 -0
  15. data/lib/ruby_llm/agents/core/configuration.rb +32 -1
  16. data/lib/ruby_llm/agents/core/version.rb +1 -1
  17. data/lib/ruby_llm/agents/pricing/data_store.rb +339 -0
  18. data/lib/ruby_llm/agents/pricing/helicone_adapter.rb +88 -0
  19. data/lib/ruby_llm/agents/pricing/litellm_adapter.rb +105 -0
  20. data/lib/ruby_llm/agents/pricing/llmpricing_adapter.rb +73 -0
  21. data/lib/ruby_llm/agents/pricing/openrouter_adapter.rb +90 -0
  22. data/lib/ruby_llm/agents/pricing/portkey_adapter.rb +94 -0
  23. data/lib/ruby_llm/agents/pricing/ruby_llm_adapter.rb +94 -0
  24. data/lib/ruby_llm/agents/results/speech_result.rb +19 -16
  25. data/lib/ruby_llm/agents/routing/class_methods.rb +92 -0
  26. data/lib/ruby_llm/agents/routing/result.rb +74 -0
  27. data/lib/ruby_llm/agents/routing.rb +140 -0
  28. data/lib/ruby_llm/agents.rb +3 -0
  29. metadata +14 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 463487c17c50bf1496a30c9eea51dab3c334a17010853da97da5624a6cf564b5
4
- data.tar.gz: 470a6666266d17dc8190f5118eec0c5f674fb51bae8b368d8713ea65d1882025
3
+ metadata.gz: 4cb913b70bd04950b91cc665ef9e2af84aa009618dffb7072e9d6b8ae389bc77
4
+ data.tar.gz: 479066023ed864ee78c317c37f64e92c8fb2b503215ac0280e097a14d9ba2ba7
5
5
  SHA512:
6
- metadata.gz: b1e2d4688dfc294c3b94c95df084a248fa25fbcd7d99910f31f72b85138bf37a392a8b350462ee341d5d209674b844a9d2692a177db30845857a586fa77ce3bc
7
- data.tar.gz: 50130237011f12c808a073d55b9083ce8449f8e0ddf8dd800c13134104f48233ea4f0fac3ddcbcc9a8b45b91814f67db5c57632dd6d54930c1ffd19fda825e96
6
+ metadata.gz: 6e521f3b9f022228e178618ff4e9eebb7de1486e96a2aec9c09215b76c16306210c6a7fd7ce0309b36e442886e4b7feb65788586b904d33e22f5b235c3d9bea2
7
+ data.tar.gz: 8a7bb1235fa527283a4296d56bd95dda94b87f2c1419f75f6efc46f6aa1e1b74957d1cd0761b9763f61f9e9bfefae15c5028b0cb8a89295b1450df3c7865580b
data/README.md CHANGED
@@ -100,6 +100,50 @@ result = Embedders::DocumentEmbedder.call(texts: ["Hello", "World", "Ruby"])
100
100
  result.vectors # => [[...], [...], [...]]
101
101
  ```
102
102
 
103
+ ```ruby
104
+ # Message classification and routing
105
+ class SupportRouter < ApplicationAgent
106
+ include RubyLLM::Agents::Routing
107
+
108
+ model "gpt-4o-mini"
109
+ temperature 0.0
110
+ cache_for 1.hour
111
+
112
+ route :billing, "Billing, charges, refunds, payments"
113
+ route :technical, "Bugs, errors, crashes, technical issues"
114
+ route :sales, "Pricing, plans, upgrades, discounts"
115
+ default_route :general
116
+ end
117
+
118
+ result = SupportRouter.call(message: "I was charged twice")
119
+ result.route # => :billing
120
+ result.total_cost # => 0.00008
121
+ ```
122
+
123
+ ```ruby
124
+ # Text-to-speech and speech-to-text
125
+ # app/agents/audio/podcast_speaker.rb
126
+ module Audio
127
+ class PodcastSpeaker < ApplicationSpeaker
128
+ model "tts-1"
129
+ voice "onyx"
130
+ speed 0.95
131
+ output_format :aac
132
+ streaming true
133
+ end
134
+ end
135
+
136
+ result = Audio::PodcastSpeaker.call(text: "Welcome to the show!")
137
+ result.audio # => Binary audio data
138
+ result.duration # => 1.5
139
+ result.save_to("episode.aac")
140
+
141
+ # Speech-to-text transcription
142
+ result = Audio::MeetingTranscriber.call(audio: "standup.mp3")
143
+ result.text # => "Good morning everyone..."
144
+ result.word_count # => 5432
145
+ ```
146
+
103
147
  ```ruby
104
148
  # Image generation, analysis, and pipelines
105
149
  # app/agents/images/logo_generator.rb
@@ -127,6 +171,7 @@ result.save("logo.png")
127
171
  | **Cost Analytics** | Track spending by agent, model, tenant, and time period | [Analytics](https://github.com/adham90/ruby_llm-agents/wiki/Execution-Tracking) |
128
172
  | **Reliability** | Automatic retries, model fallbacks, circuit breakers with block DSL | [Reliability](https://github.com/adham90/ruby_llm-agents/wiki/Reliability) |
129
173
  | **Budget Controls** | Daily/monthly limits with hard and soft enforcement | [Budgets](https://github.com/adham90/ruby_llm-agents/wiki/Budget-Controls) |
174
+ | **Multi-Source Pricing** | 7-source pricing cascade with caching for all model types | [Pricing](https://github.com/adham90/ruby_llm-agents/wiki/Pricing) |
130
175
  | **Multi-Tenancy** | Per-tenant API keys, budgets, circuit breakers, and execution isolation | [Multi-Tenancy](https://github.com/adham90/ruby_llm-agents/wiki/Multi-Tenancy) |
131
176
  | **Async/Fiber** | Concurrent execution with Ruby fibers for high-throughput workloads | [Async](https://github.com/adham90/ruby_llm-agents/wiki/Async-Fiber) |
132
177
  | **Dashboard** | Real-time Turbo-powered monitoring UI | [Dashboard](https://github.com/adham90/ruby_llm-agents/wiki/Dashboard) |
@@ -135,7 +180,8 @@ result.save("logo.png")
135
180
  | **Attachments** | Images, PDFs, and multimodal support | [Attachments](https://github.com/adham90/ruby_llm-agents/wiki/Attachments) |
136
181
  | **Embeddings** | Vector embeddings with batching, caching, and preprocessing | [Embeddings](https://github.com/adham90/ruby_llm-agents/wiki/Embeddings) |
137
182
  | **Image Operations** | Generation, analysis, editing, pipelines with cost tracking | [Images](https://github.com/adham90/ruby_llm-agents/wiki/Image-Generation) |
138
- | **Audio** | Text-to-speech (OpenAI, ElevenLabs), speech-to-text, dashboard audio playback | [Audio](https://github.com/adham90/ruby_llm-agents/wiki/Audio) |
183
+ | **Routing** | Message classification and routing with auto-generated prompts, inline classify | [Routing](https://github.com/adham90/ruby_llm-agents/wiki/Routing) |
184
+ | **Audio** | Text-to-speech (OpenAI, ElevenLabs), speech-to-text, dynamic pricing, 28+ output formats, dashboard audio playback | [Audio](https://github.com/adham90/ruby_llm-agents/wiki/Audio) |
139
185
  | **Alerts** | Slack, webhook, and custom notifications | [Alerts](https://github.com/adham90/ruby_llm-agents/wiki/Alerts) |
140
186
 
141
187
  ## Quick Start
@@ -213,10 +259,12 @@ mount RubyLLM::Agents::Engine => "/agents"
213
259
  | [Agent DSL](https://github.com/adham90/ruby_llm-agents/wiki/Agent-DSL) | All DSL options: model, temperature, params, caching, description |
214
260
  | [Reliability](https://github.com/adham90/ruby_llm-agents/wiki/Reliability) | Retries, fallbacks, circuit breakers, timeouts, reliability block |
215
261
  | [Budget Controls](https://github.com/adham90/ruby_llm-agents/wiki/Budget-Controls) | Spending limits, alerts, enforcement |
262
+ | [Pricing](https://github.com/adham90/ruby_llm-agents/wiki/Pricing) | Multi-source pricing cascade, caching, configuration |
216
263
  | [Multi-Tenancy](https://github.com/adham90/ruby_llm-agents/wiki/Multi-Tenancy) | Per-tenant budgets, isolation, configuration |
217
264
  | [Async/Fiber](https://github.com/adham90/ruby_llm-agents/wiki/Async-Fiber) | Concurrent execution with Ruby fibers |
218
265
  | [Testing Agents](https://github.com/adham90/ruby_llm-agents/wiki/Testing-Agents) | RSpec patterns, mocking, dry_run mode |
219
266
  | [Error Handling](https://github.com/adham90/ruby_llm-agents/wiki/Error-Handling) | Error types, recovery patterns |
267
+ | [Routing](https://github.com/adham90/ruby_llm-agents/wiki/Routing) | Message classification, routing DSL, inline classify |
220
268
  | [Embeddings](https://github.com/adham90/ruby_llm-agents/wiki/Embeddings) | Vector embeddings, batching, caching, preprocessing |
221
269
  | [Image Generation](https://github.com/adham90/ruby_llm-agents/wiki/Image-Generation) | Text-to-image, templates, pipelines, cost tracking |
222
270
  | [Dashboard](https://github.com/adham90/ruby_llm-agents/wiki/Dashboard) | Setup, authentication, analytics |
@@ -50,7 +50,8 @@ module RubyLLM
50
50
  embedder: @agents.select { |a| a[:agent_type] == "embedder" },
51
51
  speaker: @agents.select { |a| a[:agent_type] == "speaker" },
52
52
  transcriber: @agents.select { |a| a[:agent_type] == "transcriber" },
53
- image_generator: @agents.select { |a| a[:agent_type] == "image_generator" }
53
+ image_generator: @agents.select { |a| a[:agent_type] == "image_generator" },
54
+ router: @agents.select { |a| a[:agent_type] == "router" }
54
55
  }
55
56
 
56
57
  @agent_count = @agents.size
@@ -59,7 +60,7 @@ module RubyLLM
59
60
  Rails.logger.error("[RubyLLM::Agents] Error loading agents: #{e.message}")
60
61
  @agents = []
61
62
  @deleted_agents = []
62
- @agents_by_type = {agent: [], embedder: [], speaker: [], transcriber: [], image_generator: []}
63
+ @agents_by_type = {agent: [], embedder: [], speaker: [], transcriber: [], image_generator: [], router: []}
63
64
  @agent_count = 0
64
65
  @deleted_count = 0
65
66
  @sort_params = {column: DEFAULT_AGENT_SORT_COLUMN, direction: DEFAULT_AGENT_SORT_DIRECTION}
@@ -85,8 +86,8 @@ module RubyLLM
85
86
 
86
87
  if @agent_class
87
88
  load_agent_config
88
- # Only load circuit breaker status for base agents
89
- load_circuit_breaker_status if @agent_type_kind == "agent"
89
+ # Load circuit breaker status for agents that support reliability
90
+ load_circuit_breaker_status if @agent_type_kind.in?(%w[agent router])
90
91
  end
91
92
  rescue => e
92
93
  Rails.logger.error("[RubyLLM::Agents] Error loading agent #{@agent_type}: #{e.message}")
@@ -207,6 +208,8 @@ module RubyLLM
207
208
  load_transcriber_config
208
209
  when "image_generator"
209
210
  load_image_generator_config
211
+ when "router"
212
+ load_router_config
210
213
  else
211
214
  load_base_agent_config
212
215
  end
@@ -291,6 +294,26 @@ module RubyLLM
291
294
  )
292
295
  end
293
296
 
297
+ # Loads configuration specific to Router agents
298
+ #
299
+ # @return [void]
300
+ def load_router_config
301
+ routes = safe_config_call(:routes) || {}
302
+ @config.merge!(
303
+ temperature: safe_config_call(:temperature),
304
+ timeout: safe_config_call(:timeout),
305
+ cache_enabled: safe_config_call(:cache_enabled?) || false,
306
+ cache_ttl: safe_config_call(:cache_ttl),
307
+ default_route: safe_config_call(:default_route_name),
308
+ routes: routes.transform_values { |v| v[:description] },
309
+ route_count: routes.size,
310
+ retries: safe_config_call(:retries),
311
+ fallback_models: safe_config_call(:fallback_models),
312
+ total_timeout: safe_config_call(:total_timeout),
313
+ circuit_breaker: safe_config_call(:circuit_breaker_config)
314
+ )
315
+ end
316
+
294
317
  # Safely calls a method on the agent class, returning nil on error
295
318
  #
296
319
  # @param method [Symbol] The method to call
@@ -176,7 +176,7 @@ module RubyLLM
176
176
  # Detects the agent type from class hierarchy
177
177
  #
178
178
  # @param agent_class [Class, nil] The agent class
179
- # @return [String] "agent", "embedder", "speaker", "transcriber", or "image_generator"
179
+ # @return [String] "agent", "embedder", "speaker", "transcriber", "image_generator", or "router"
180
180
  def detect_agent_type(agent_class)
181
181
  return "agent" unless agent_class
182
182
 
@@ -190,6 +190,8 @@ module RubyLLM
190
190
  "transcriber"
191
191
  elsif ancestors.include?("RubyLLM::Agents::ImageGenerator")
192
192
  "image_generator"
193
+ elsif agent_class.respond_to?(:routes) && agent_class.ancestors.any? { |a| a.name.to_s == "RubyLLM::Agents::Routing" }
194
+ "router"
193
195
  else
194
196
  "agent"
195
197
  end
@@ -0,0 +1,110 @@
1
+ <%# Configuration partial for Router agent types - grid layout %>
2
+ <%
3
+ routes = config[:routes] || {}
4
+ default_route = config[:default_route] || :general
5
+ retries_config = config[:retries] || {}
6
+ fallback_models = Array(config[:fallback_models]).compact
7
+ has_retries = (retries_config[:max] || 0) > 0
8
+ has_fallbacks = fallback_models.any?
9
+ has_total_timeout = config[:total_timeout].present?
10
+ has_circuit_breaker = config[:circuit_breaker].present?
11
+ has_any_reliability = has_retries || has_fallbacks || has_total_timeout || has_circuit_breaker
12
+ %>
13
+
14
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 font-mono text-xs">
15
+ <!-- Basic -->
16
+ <div>
17
+ <span class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">basic</span>
18
+ <div class="mt-1.5 space-y-0.5">
19
+ <div class="flex items-center gap-3 py-1">
20
+ <span class="w-20 text-gray-400 dark:text-gray-600">model</span>
21
+ <span class="text-gray-900 dark:text-gray-200"><%= config[:model] %></span>
22
+ </div>
23
+ <div class="flex items-center gap-3 py-1">
24
+ <span class="w-20 text-gray-400 dark:text-gray-600">temperature</span>
25
+ <span class="text-gray-900 dark:text-gray-200"><%= config[:temperature] %></span>
26
+ </div>
27
+ <div class="flex items-center gap-3 py-1">
28
+ <span class="w-20 text-gray-400 dark:text-gray-600">timeout</span>
29
+ <span class="text-gray-900 dark:text-gray-200"><%= config[:timeout] %>s</span>
30
+ </div>
31
+ <div class="flex items-center gap-3 py-1">
32
+ <span class="w-20 text-gray-400 dark:text-gray-600">cache</span>
33
+ <% if config[:cache_enabled] %>
34
+ <span class="text-green-500">enabled</span>
35
+ <span class="text-gray-400 dark:text-gray-600">(<%= config[:cache_ttl].inspect %>)</span>
36
+ <% else %>
37
+ <span class="text-gray-400 dark:text-gray-600">disabled</span>
38
+ <% end %>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <!-- Routes -->
44
+ <div>
45
+ <span class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">routes (<%= routes.size %>)</span>
46
+ <% if routes.any? %>
47
+ <div class="mt-1.5 space-y-0.5">
48
+ <% routes.each do |name, description| %>
49
+ <div class="flex items-center gap-2 py-1">
50
+ <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= name.to_sym == default_route ? 'bg-cyan-500' : 'bg-gray-400' %>"></span>
51
+ <span class="w-24 text-gray-900 dark:text-gray-200 truncate"><%= name %></span>
52
+ <span class="text-gray-400 dark:text-gray-600 truncate"><%= description %></span>
53
+ </div>
54
+ <% end %>
55
+ <div class="flex items-center gap-2 py-1 mt-0.5">
56
+ <span class="w-1.5 flex-shrink-0"></span>
57
+ <span class="text-[10px] text-gray-400 dark:text-gray-600">default: <span class="text-cyan-600 dark:text-cyan-400"><%= default_route %></span></span>
58
+ </div>
59
+ </div>
60
+ <% else %>
61
+ <div class="mt-1 py-1 text-gray-400 dark:text-gray-600">no routes defined</div>
62
+ <% end %>
63
+ </div>
64
+
65
+ <% if has_any_reliability %>
66
+ <!-- Reliability -->
67
+ <div class="md:col-span-2">
68
+ <span class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">reliability</span>
69
+ <div class="mt-1.5 flex flex-wrap gap-x-8 gap-y-0.5">
70
+ <div class="flex items-center gap-2 py-1">
71
+ <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= has_retries ? 'bg-green-500' : 'bg-gray-400' %>"></span>
72
+ <span class="w-16 text-gray-400 dark:text-gray-600">retries</span>
73
+ <% if has_retries %>
74
+ <span class="text-gray-900 dark:text-gray-200"><%= retries_config[:max] %> max</span>
75
+ <% else %>
76
+ <span class="text-gray-400 dark:text-gray-600">&mdash;</span>
77
+ <% end %>
78
+ </div>
79
+ <div class="flex items-center gap-2 py-1">
80
+ <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= has_fallbacks ? 'bg-green-500' : 'bg-gray-400' %>"></span>
81
+ <span class="w-16 text-gray-400 dark:text-gray-600">fallbacks</span>
82
+ <% if has_fallbacks %>
83
+ <span class="text-gray-900 dark:text-gray-200 truncate"><%= fallback_models.join(" → ") %></span>
84
+ <% else %>
85
+ <span class="text-gray-400 dark:text-gray-600">&mdash;</span>
86
+ <% end %>
87
+ </div>
88
+ <div class="flex items-center gap-2 py-1">
89
+ <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= has_total_timeout ? 'bg-green-500' : 'bg-gray-400' %>"></span>
90
+ <span class="w-16 text-gray-400 dark:text-gray-600">timeout</span>
91
+ <% if has_total_timeout %>
92
+ <span class="text-gray-900 dark:text-gray-200"><%= config[:total_timeout] %>s total</span>
93
+ <% else %>
94
+ <span class="text-gray-400 dark:text-gray-600">&mdash;</span>
95
+ <% end %>
96
+ </div>
97
+ <div class="flex items-center gap-2 py-1">
98
+ <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= has_circuit_breaker ? 'bg-green-500' : 'bg-gray-400' %>"></span>
99
+ <span class="w-16 text-gray-400 dark:text-gray-600">breaker</span>
100
+ <% if has_circuit_breaker %>
101
+ <% cb = config[:circuit_breaker] %>
102
+ <span class="text-gray-900 dark:text-gray-200"><%= cb[:errors] %>/<%= cb[:within] %>s</span>
103
+ <% else %>
104
+ <span class="text-gray-400 dark:text-gray-600">&mdash;</span>
105
+ <% end %>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ <% end %>
110
+ </div>
@@ -17,6 +17,7 @@
17
17
  embedder: <%= @agents_by_type[:embedder].size %>,
18
18
  audio: <%= audio_count %>,
19
19
  image_generator: <%= @agents_by_type[:image_generator].size %>,
20
+ router: <%= @agents_by_type[:router].size %>,
20
21
  deleted: <%= @deleted_count %>
21
22
  },
22
23
  get currentCount() {
@@ -62,6 +63,11 @@
62
63
  :class="activeSubTab === 'image_generator' ? 'text-gray-900 dark:text-gray-100' : 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
63
64
  class="px-2 py-0.5">image</button>
64
65
  <% end %>
66
+ <% if @agents_by_type[:router].size > 0 %>
67
+ <button type="button" @click="activeSubTab = 'router'; updateUrl()"
68
+ :class="activeSubTab === 'router' ? 'text-gray-900 dark:text-gray-100' : 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
69
+ class="px-2 py-0.5">routers</button>
70
+ <% end %>
65
71
  <% if @deleted_count > 0 %>
66
72
  <button type="button" @click="activeSubTab = 'deleted'; updateUrl()"
67
73
  :class="activeSubTab === 'deleted' ? 'text-gray-900 dark:text-gray-100' : 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300'"
@@ -21,6 +21,7 @@
21
21
  secondary_badges << { label: @execution.finish_reason, css: finish_css }
22
22
  end
23
23
  secondary_badges << { label: "rate limited", css: "badge-orange" } if @execution.respond_to?(:rate_limited?) && @execution.rate_limited?
24
+ secondary_badges << { label: "no pricing", css: "badge-orange" } if @execution.metadata&.dig("pricing_warning")
24
25
  %>
25
26
  <div class="flex flex-wrap items-center gap-3 mb-1.5">
26
27
  <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono"><%= @execution.agent_type.gsub(/Agent$/, '') %></span>
@@ -47,6 +48,15 @@
47
48
  <%= time_ago_in_words(@execution.created_at) %> ago
48
49
  </div>
49
50
 
51
+ <!-- Pricing warning -->
52
+ <% if @execution.metadata&.dig("pricing_warning") %>
53
+ <div class="flex items-center gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded px-3 py-2 mb-2">
54
+ <span class="text-amber-600 dark:text-amber-400 text-xs font-mono">
55
+ &#9888; <%= @execution.metadata["pricing_warning"] %>
56
+ </span>
57
+ </div>
58
+ <% end %>
59
+
50
60
  <!-- Stats inline row -->
51
61
  <div class="flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-xs text-gray-400 dark:text-gray-500 mb-2">
52
62
  <span><span class="text-gray-800 dark:text-gray-200"><%= number_to_human_short(@execution.duration_ms || 0) %>ms</span> duration</span>
@@ -50,6 +50,14 @@
50
50
  text: "text-pink-700 dark:text-pink-300",
51
51
  icon_char: "🎨"
52
52
  }
53
+ when "router"
54
+ {
55
+ icon: "router",
56
+ label: "Router",
57
+ bg: "bg-cyan-100 dark:bg-cyan-500/20",
58
+ text: "text-cyan-700 dark:text-cyan-300",
59
+ icon_char: "🔀"
60
+ }
53
61
  else
54
62
  {
55
63
  icon: "question",
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module RubyLLM
7
+ module Agents
8
+ module Audio
9
+ module ElevenLabs
10
+ # Fetches and caches ElevenLabs model data from the /v1/models API.
11
+ #
12
+ # Used for:
13
+ # - Dynamic cost calculation via character_cost_multiplier
14
+ # - Model validation (TTS vs STS capability)
15
+ # - Capability awareness (style, speaker_boost, max chars, languages)
16
+ #
17
+ # @example Check if a model supports TTS
18
+ # ElevenLabs::ModelRegistry.tts_model?("eleven_v3") # => true
19
+ # ElevenLabs::ModelRegistry.tts_model?("eleven_english_sts_v2") # => false
20
+ #
21
+ # @example Get cost multiplier
22
+ # ElevenLabs::ModelRegistry.cost_multiplier("eleven_flash_v2_5") # => 0.5
23
+ #
24
+ module ModelRegistry
25
+ extend self
26
+
27
+ # Returns all models from the ElevenLabs API (cached)
28
+ #
29
+ # @return [Array<Hash>] Array of model hashes
30
+ def models
31
+ @mutex ||= Mutex.new
32
+ @mutex.synchronize do
33
+ if @models && !cache_expired?
34
+ return @models
35
+ end
36
+
37
+ @models = fetch_models
38
+ @fetched_at = Time.now
39
+ @models
40
+ end
41
+ end
42
+
43
+ # Find a specific model by ID
44
+ #
45
+ # @param model_id [String] The model identifier
46
+ # @return [Hash, nil] Model hash or nil if not found
47
+ def find(model_id)
48
+ models.find { |m| m["model_id"] == model_id.to_s }
49
+ end
50
+
51
+ # Check if model supports text-to-speech
52
+ #
53
+ # @param model_id [String] The model identifier
54
+ # @return [Boolean]
55
+ def tts_model?(model_id)
56
+ model = find(model_id)
57
+ return false unless model
58
+
59
+ model["can_do_text_to_speech"] == true
60
+ end
61
+
62
+ # Get character_cost_multiplier for a model
63
+ #
64
+ # @param model_id [String] The model identifier
65
+ # @return [Float] Cost multiplier (defaults to 1.0 for unknown models)
66
+ def cost_multiplier(model_id)
67
+ model = find(model_id)
68
+ model&.dig("model_rates", "character_cost_multiplier") || 1.0
69
+ end
70
+
71
+ # Get max characters per request for a model
72
+ #
73
+ # @param model_id [String] The model identifier
74
+ # @return [Integer, nil] Max characters or nil if unknown
75
+ def max_characters(model_id)
76
+ model = find(model_id)
77
+ model&.dig("maximum_text_length_per_request")
78
+ end
79
+
80
+ # Get supported language IDs for a model
81
+ #
82
+ # @param model_id [String] The model identifier
83
+ # @return [Array<String>] Language IDs (e.g. ["en", "es", "ja"])
84
+ def languages(model_id)
85
+ model = find(model_id)
86
+ model&.dig("languages")&.map { |l| l["language_id"] } || []
87
+ end
88
+
89
+ # Check if model supports the style voice setting
90
+ #
91
+ # @param model_id [String] The model identifier
92
+ # @return [Boolean]
93
+ def supports_style?(model_id)
94
+ find(model_id)&.dig("can_use_style") == true
95
+ end
96
+
97
+ # Check if model supports the speaker_boost setting
98
+ #
99
+ # @param model_id [String] The model identifier
100
+ # @return [Boolean]
101
+ def supports_speaker_boost?(model_id)
102
+ find(model_id)&.dig("can_use_speaker_boost") == true
103
+ end
104
+
105
+ # Check if model supports voice conversion (speech-to-speech)
106
+ # Used by VoiceConverter agent (see plans/elevenlabs_voice_converter.md)
107
+ #
108
+ # @param model_id [String] The model identifier
109
+ # @return [Boolean]
110
+ def voice_conversion_model?(model_id)
111
+ model = find(model_id)
112
+ return false unless model
113
+
114
+ model["can_do_voice_conversion"] == true
115
+ end
116
+
117
+ # Force refresh the cache
118
+ #
119
+ # @return [Array<Hash>] Fresh model data
120
+ def refresh!
121
+ @mutex ||= Mutex.new
122
+ @mutex.synchronize do
123
+ @models = nil
124
+ @fetched_at = nil
125
+ end
126
+ models
127
+ end
128
+
129
+ # Clear cache without re-fetching (useful for tests)
130
+ #
131
+ # @return [void]
132
+ def clear_cache!
133
+ @mutex ||= Mutex.new
134
+ @mutex.synchronize do
135
+ @models = nil
136
+ @fetched_at = nil
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def fetch_models
143
+ return [] unless api_key
144
+
145
+ response = connection.get("/v1/models")
146
+
147
+ if response.success?
148
+ parsed = JSON.parse(response.body)
149
+ parsed.is_a?(Array) ? parsed : []
150
+ else
151
+ warn "[RubyLLM::Agents] ElevenLabs /v1/models returned HTTP #{response.status}"
152
+ @models || []
153
+ end
154
+ rescue Faraday::Error, JSON::ParserError => e
155
+ warn "[RubyLLM::Agents] Failed to fetch ElevenLabs models: #{e.message}"
156
+ @models || []
157
+ end
158
+
159
+ def cache_expired?
160
+ return true unless @fetched_at
161
+
162
+ ttl = RubyLLM::Agents.configuration.elevenlabs_models_cache_ttl || 21_600
163
+ Time.now - @fetched_at > ttl
164
+ end
165
+
166
+ def api_key
167
+ RubyLLM::Agents.configuration.elevenlabs_api_key
168
+ end
169
+
170
+ def api_base
171
+ base = RubyLLM::Agents.configuration.elevenlabs_api_base
172
+ (base && !base.empty?) ? base : "https://api.elevenlabs.io"
173
+ end
174
+
175
+ def connection
176
+ Faraday.new(url: api_base) do |f|
177
+ f.headers["xi-api-key"] = api_key
178
+ f.adapter Faraday.default_adapter
179
+ f.options.timeout = 10
180
+ f.options.open_timeout = 5
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -4,6 +4,7 @@ require "digest"
4
4
  require_relative "../results/speech_result"
5
5
  require_relative "speech_client"
6
6
  require_relative "speech_pricing"
7
+ require_relative "elevenlabs/model_registry"
7
8
 
8
9
  module RubyLLM
9
10
  module Agents
@@ -409,6 +410,7 @@ module RubyLLM
409
410
 
410
411
  # Executes speech synthesis
411
412
  def execute_speech(processed_text)
413
+ validate_elevenlabs_model!(processed_text)
412
414
  speak_options = build_speak_options
413
415
 
414
416
  if streaming_enabled? && @streaming_block
@@ -418,6 +420,42 @@ module RubyLLM
418
420
  end
419
421
  end
420
422
 
423
+ # Validates ElevenLabs model capabilities before calling the API.
424
+ # Raises on hard errors (non-TTS model), warns on soft issues.
425
+ def validate_elevenlabs_model!(text)
426
+ return unless resolved_provider == :elevenlabs
427
+ return unless defined?(Audio::ElevenLabs::ModelRegistry)
428
+
429
+ model_id = resolved_model
430
+ model = Audio::ElevenLabs::ModelRegistry.find(model_id)
431
+ return unless model # Unknown model — skip validation
432
+
433
+ # Hard error: model doesn't support TTS at all
434
+ unless model["can_do_text_to_speech"] == true
435
+ raise ConfigurationError,
436
+ "ElevenLabs model '#{model_id}' does not support text-to-speech. " \
437
+ "It may be a speech-to-speech model. Use a TTS-capable model like 'eleven_v3'."
438
+ end
439
+
440
+ # Warn: text exceeds model's max character limit
441
+ max_chars = model["maximum_text_length_per_request"]
442
+ if max_chars && text.length > max_chars
443
+ warn "[RubyLLM::Agents] Text length (#{text.length}) exceeds " \
444
+ "#{model_id} max of #{max_chars} characters. The API may truncate or reject it."
445
+ end
446
+
447
+ # Warn: style used on model that doesn't support it
448
+ vs = self.class.voice_settings_config
449
+ if vs&.style_value && vs.style_value > 0 && model["can_use_style"] != true
450
+ warn "[RubyLLM::Agents] Model '#{model_id}' does not support the 'style' voice setting. It will be ignored."
451
+ end
452
+ rescue ConfigurationError
453
+ raise
454
+ rescue => e
455
+ # Don't block speech on validation errors
456
+ warn "[RubyLLM::Agents] ElevenLabs model validation failed: #{e.message}"
457
+ end
458
+
421
459
  # Executes standard (non-streaming) speech synthesis
422
460
  def execute_standard_speech(text, options)
423
461
  response = speech_client.speak(
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "faraday"
4
4
  require "json"
5
+ require "set"
5
6
 
6
7
  module RubyLLM
7
8
  module Agents
@@ -266,14 +267,37 @@ module RubyLLM
266
267
  body
267
268
  end
268
269
 
270
+ # Convenience mapping: simple symbol → ElevenLabs native format string
269
271
  ELEVENLABS_FORMAT_MAP = {
270
272
  "mp3" => "mp3_44100_128",
271
- "pcm" => "pcm_44100",
273
+ "wav" => "wav_44100",
274
+ "ogg" => "mp3_44100_128", # ElevenLabs doesn't support ogg; fallback to mp3
275
+ "pcm" => "pcm_24000",
276
+ "opus" => "opus_48000_128",
277
+ "flac" => "mp3_44100_128", # ElevenLabs doesn't support flac; fallback to mp3
278
+ "aac" => "mp3_44100_128", # ElevenLabs doesn't support aac; fallback to mp3
279
+ "alaw" => "alaw_8000",
272
280
  "ulaw" => "ulaw_8000"
273
281
  }.freeze
274
282
 
283
+ # All valid ElevenLabs native format strings (pass-through)
284
+ ELEVENLABS_NATIVE_FORMATS = Set.new(%w[
285
+ mp3_22050_32 mp3_24000_48 mp3_44100_32 mp3_44100_64
286
+ mp3_44100_96 mp3_44100_128 mp3_44100_192
287
+ pcm_8000 pcm_16000 pcm_22050 pcm_24000 pcm_32000 pcm_44100 pcm_48000
288
+ wav_8000 wav_16000 wav_22050 wav_24000 wav_32000 wav_44100 wav_48000
289
+ opus_48000_32 opus_48000_64 opus_48000_96 opus_48000_128 opus_48000_192
290
+ alaw_8000 ulaw_8000
291
+ ]).freeze
292
+
275
293
  def elevenlabs_output_format(format)
276
- ELEVENLABS_FORMAT_MAP[format.to_s] || "mp3_44100_128"
294
+ format_str = format.to_s
295
+
296
+ # Pass through native ElevenLabs format strings directly
297
+ return format_str if ELEVENLABS_NATIVE_FORMATS.include?(format_str)
298
+
299
+ # Map simple symbols to native formats
300
+ ELEVENLABS_FORMAT_MAP[format_str] || "mp3_44100_128"
277
301
  end
278
302
 
279
303
  def elevenlabs_connection