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.
- checksums.yaml +4 -4
- data/README.md +49 -1
- data/app/controllers/ruby_llm/agents/agents_controller.rb +27 -4
- data/app/services/ruby_llm/agents/agent_registry.rb +3 -1
- data/app/views/ruby_llm/agents/agents/_config_router.html.erb +110 -0
- data/app/views/ruby_llm/agents/agents/index.html.erb +6 -0
- data/app/views/ruby_llm/agents/executions/show.html.erb +10 -0
- data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +8 -0
- data/lib/ruby_llm/agents/audio/elevenlabs/model_registry.rb +187 -0
- data/lib/ruby_llm/agents/audio/speaker.rb +38 -0
- data/lib/ruby_llm/agents/audio/speech_client.rb +26 -2
- data/lib/ruby_llm/agents/audio/speech_pricing.rb +44 -3
- data/lib/ruby_llm/agents/audio/transcriber.rb +26 -15
- data/lib/ruby_llm/agents/audio/transcription_pricing.rb +226 -0
- data/lib/ruby_llm/agents/core/configuration.rb +32 -1
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/pricing/data_store.rb +339 -0
- data/lib/ruby_llm/agents/pricing/helicone_adapter.rb +88 -0
- data/lib/ruby_llm/agents/pricing/litellm_adapter.rb +105 -0
- data/lib/ruby_llm/agents/pricing/llmpricing_adapter.rb +73 -0
- data/lib/ruby_llm/agents/pricing/openrouter_adapter.rb +90 -0
- data/lib/ruby_llm/agents/pricing/portkey_adapter.rb +94 -0
- data/lib/ruby_llm/agents/pricing/ruby_llm_adapter.rb +94 -0
- data/lib/ruby_llm/agents/results/speech_result.rb +19 -16
- data/lib/ruby_llm/agents/routing/class_methods.rb +92 -0
- data/lib/ruby_llm/agents/routing/result.rb +74 -0
- data/lib/ruby_llm/agents/routing.rb +140 -0
- data/lib/ruby_llm/agents.rb +3 -0
- metadata +14 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4cb913b70bd04950b91cc665ef9e2af84aa009618dffb7072e9d6b8ae389bc77
|
|
4
|
+
data.tar.gz: 479066023ed864ee78c317c37f64e92c8fb2b503215ac0280e097a14d9ba2ba7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| **
|
|
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
|
-
#
|
|
89
|
-
load_circuit_breaker_status if @agent_type_kind
|
|
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 "
|
|
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">—</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">—</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">—</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">—</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
|
+
⚠ <%= @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
|
-
"
|
|
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
|
-
|
|
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
|