ruby_llm-agents 3.4.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 +48 -0
- 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/speaker.rb +1 -1
- 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 +25 -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/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 +13 -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,6 +180,7 @@ 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) |
|
|
183
|
+
| **Routing** | Message classification and routing with auto-generated prompts, inline classify | [Routing](https://github.com/adham90/ruby_llm-agents/wiki/Routing) |
|
|
138
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
|
|
|
@@ -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",
|
|
@@ -446,7 +446,7 @@ module RubyLLM
|
|
|
446
446
|
|
|
447
447
|
# Warn: style used on model that doesn't support it
|
|
448
448
|
vs = self.class.voice_settings_config
|
|
449
|
-
if vs
|
|
449
|
+
if vs&.style_value && vs.style_value > 0 && model["can_use_style"] != true
|
|
450
450
|
warn "[RubyLLM::Agents] Model '#{model_id}' does not support the 'style' voice setting. It will be ignored."
|
|
451
451
|
end
|
|
452
452
|
rescue ConfigurationError
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require_relative "../results/transcription_result"
|
|
5
|
+
require_relative "transcription_pricing"
|
|
5
6
|
|
|
6
7
|
module RubyLLM
|
|
7
8
|
module Agents
|
|
@@ -318,6 +319,12 @@ module RubyLLM
|
|
|
318
319
|
context.output_tokens = 0
|
|
319
320
|
context.total_cost = calculate_cost(raw_result)
|
|
320
321
|
|
|
322
|
+
# Store pricing warning if cost calculation returned nil
|
|
323
|
+
if @pricing_warning
|
|
324
|
+
context[:pricing_warning] = @pricing_warning
|
|
325
|
+
Rails.logger.warn(@pricing_warning) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
326
|
+
end
|
|
327
|
+
|
|
321
328
|
# Store transcription-specific metadata for execution tracking
|
|
322
329
|
context[:language] = resolved_language if resolved_language
|
|
323
330
|
context[:detected_language] = raw_result[:language] if raw_result[:language]
|
|
@@ -615,30 +622,34 @@ module RubyLLM
|
|
|
615
622
|
# Calculates cost for transcription
|
|
616
623
|
#
|
|
617
624
|
# @param raw_result [Hash] Raw transcription result
|
|
618
|
-
# @return [Float] Cost in USD
|
|
625
|
+
# @return [Float] Cost in USD (0 if no pricing found)
|
|
619
626
|
def calculate_cost(raw_result)
|
|
620
|
-
|
|
621
|
-
duration_minutes = raw_result[:duration] ? raw_result[:duration] / 60.0 : 0
|
|
627
|
+
@pricing_warning = nil
|
|
622
628
|
|
|
623
|
-
# Check if response has cost info
|
|
629
|
+
# Check if response has cost info from the API
|
|
624
630
|
if raw_result[:raw_response].respond_to?(:cost) && raw_result[:raw_response].cost
|
|
625
631
|
return raw_result[:raw_response].cost
|
|
626
632
|
end
|
|
627
633
|
|
|
628
|
-
#
|
|
634
|
+
# Delegate to TranscriptionPricing (2-tier: LiteLLM + user config)
|
|
629
635
|
model = raw_result[:model].to_s
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
636
|
+
duration = raw_result[:duration] || 0
|
|
637
|
+
|
|
638
|
+
cost = Audio::TranscriptionPricing.calculate_cost(
|
|
639
|
+
model_id: model,
|
|
640
|
+
duration_seconds: duration
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
if cost.nil?
|
|
644
|
+
@pricing_warning = "[RubyLLM::Agents] No pricing found for transcription model '#{model}'. " \
|
|
645
|
+
"Cost recorded as $0. Add pricing to your config:\n" \
|
|
646
|
+
" RubyLLM::Agents.configure do |c|\n" \
|
|
647
|
+
" c.transcription_model_pricing = { \"#{model}\" => 0.006 } # price per minute\n" \
|
|
648
|
+
" end"
|
|
649
|
+
return 0
|
|
639
650
|
end
|
|
640
651
|
|
|
641
|
-
|
|
652
|
+
cost
|
|
642
653
|
end
|
|
643
654
|
|
|
644
655
|
# Resolves the model to use
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pricing/data_store"
|
|
4
|
+
require_relative "../pricing/ruby_llm_adapter"
|
|
5
|
+
require_relative "../pricing/litellm_adapter"
|
|
6
|
+
require_relative "../pricing/portkey_adapter"
|
|
7
|
+
require_relative "../pricing/openrouter_adapter"
|
|
8
|
+
require_relative "../pricing/helicone_adapter"
|
|
9
|
+
require_relative "../pricing/llmpricing_adapter"
|
|
10
|
+
|
|
11
|
+
module RubyLLM
|
|
12
|
+
module Agents
|
|
13
|
+
module Audio
|
|
14
|
+
# Dynamic pricing resolution for audio transcription models.
|
|
15
|
+
#
|
|
16
|
+
# Cascades through multiple pricing sources to maximize coverage:
|
|
17
|
+
# 1. User config (instant, always wins)
|
|
18
|
+
# 2. RubyLLM gem (local, no HTTP, already a dependency)
|
|
19
|
+
# 3. LiteLLM (bulk, most comprehensive for transcription)
|
|
20
|
+
# 4. Portkey AI (per-model, good transcription coverage)
|
|
21
|
+
# 5. OpenRouter (bulk, audio-capable chat models only)
|
|
22
|
+
# 6. Helicone (text LLM only — pass-through, future-proof)
|
|
23
|
+
# 7. LLM Pricing AI (text LLM only — pass-through, future-proof)
|
|
24
|
+
#
|
|
25
|
+
# When no pricing is found, methods return nil to signal the caller
|
|
26
|
+
# should warn the user with actionable configuration instructions.
|
|
27
|
+
#
|
|
28
|
+
# All prices are per minute of audio.
|
|
29
|
+
#
|
|
30
|
+
# @example Get cost for a transcription
|
|
31
|
+
# TranscriptionPricing.calculate_cost(model_id: "whisper-1", duration_seconds: 120)
|
|
32
|
+
# # => 0.012 (or nil if no pricing found)
|
|
33
|
+
#
|
|
34
|
+
# @example User-configured pricing
|
|
35
|
+
# RubyLLM::Agents.configure do |c|
|
|
36
|
+
# c.transcription_model_pricing = { "whisper-1" => 0.006 }
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
module TranscriptionPricing
|
|
40
|
+
extend self
|
|
41
|
+
|
|
42
|
+
LITELLM_PRICING_URL = Pricing::DataStore::LITELLM_URL
|
|
43
|
+
|
|
44
|
+
SOURCES = [:config, :ruby_llm, :litellm, :portkey, :openrouter, :helicone, :llmpricing].freeze
|
|
45
|
+
|
|
46
|
+
# Calculate total cost for a transcription operation
|
|
47
|
+
#
|
|
48
|
+
# @param model_id [String] The model identifier
|
|
49
|
+
# @param duration_seconds [Numeric] Duration of audio in seconds
|
|
50
|
+
# @return [Float, nil] Total cost in USD, or nil if no pricing found
|
|
51
|
+
def calculate_cost(model_id:, duration_seconds:)
|
|
52
|
+
price = cost_per_minute(model_id)
|
|
53
|
+
return nil unless price
|
|
54
|
+
|
|
55
|
+
duration_minutes = duration_seconds / 60.0
|
|
56
|
+
(duration_minutes * price).round(6)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get cost per minute for a transcription model
|
|
60
|
+
#
|
|
61
|
+
# @param model_id [String] Model identifier
|
|
62
|
+
# @return [Float, nil] Cost per minute in USD, or nil if not found
|
|
63
|
+
def cost_per_minute(model_id)
|
|
64
|
+
SOURCES.each do |source|
|
|
65
|
+
price = send(:"from_#{source}", model_id)
|
|
66
|
+
return price if price
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check whether pricing is available for a model
|
|
72
|
+
#
|
|
73
|
+
# @param model_id [String] Model identifier
|
|
74
|
+
# @return [Boolean] true if pricing is available
|
|
75
|
+
def pricing_found?(model_id)
|
|
76
|
+
!cost_per_minute(model_id).nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Force refresh of cached pricing data
|
|
80
|
+
def refresh!
|
|
81
|
+
Pricing::DataStore.refresh!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Expose all known pricing for debugging/dashboard
|
|
85
|
+
#
|
|
86
|
+
# @return [Hash] Pricing from all tiers
|
|
87
|
+
def all_pricing
|
|
88
|
+
{
|
|
89
|
+
ruby_llm: {}, # local gem, per-model lookup
|
|
90
|
+
litellm: litellm_transcription_models,
|
|
91
|
+
portkey: {}, # per-model, populated on demand
|
|
92
|
+
openrouter: {}, # no dedicated transcription models
|
|
93
|
+
helicone: {}, # no transcription models
|
|
94
|
+
configured: config.transcription_model_pricing || {}
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# ============================================================
|
|
101
|
+
# Tier 1: User configuration (highest priority)
|
|
102
|
+
# ============================================================
|
|
103
|
+
|
|
104
|
+
def from_config(model_id)
|
|
105
|
+
table = config.transcription_model_pricing
|
|
106
|
+
return nil unless table.is_a?(Hash) && !table.empty?
|
|
107
|
+
|
|
108
|
+
normalized = normalize_model_id(model_id)
|
|
109
|
+
|
|
110
|
+
price = table[model_id] || table[normalized] ||
|
|
111
|
+
table[model_id.to_sym] || table[normalized.to_sym]
|
|
112
|
+
|
|
113
|
+
price if price.is_a?(Numeric)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ============================================================
|
|
117
|
+
# Tier 2: RubyLLM gem (local, no HTTP)
|
|
118
|
+
# ============================================================
|
|
119
|
+
|
|
120
|
+
def from_ruby_llm(model_id)
|
|
121
|
+
data = Pricing::RubyLLMAdapter.find_model(model_id)
|
|
122
|
+
return nil unless data
|
|
123
|
+
|
|
124
|
+
extract_per_minute(data)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ============================================================
|
|
128
|
+
# Tier 3: LiteLLM
|
|
129
|
+
# ============================================================
|
|
130
|
+
|
|
131
|
+
def from_litellm(model_id)
|
|
132
|
+
data = Pricing::LiteLLMAdapter.find_model(model_id)
|
|
133
|
+
return nil unless data
|
|
134
|
+
|
|
135
|
+
extract_per_minute(data)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ============================================================
|
|
139
|
+
# Tier 4: Portkey AI
|
|
140
|
+
# ============================================================
|
|
141
|
+
|
|
142
|
+
def from_portkey(model_id)
|
|
143
|
+
data = Pricing::PortkeyAdapter.find_model(model_id)
|
|
144
|
+
return nil unless data
|
|
145
|
+
|
|
146
|
+
extract_per_minute(data)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ============================================================
|
|
150
|
+
# Tier 5: OpenRouter (audio-capable chat models only)
|
|
151
|
+
# ============================================================
|
|
152
|
+
|
|
153
|
+
def from_openrouter(model_id)
|
|
154
|
+
data = Pricing::OpenRouterAdapter.find_model(model_id)
|
|
155
|
+
return nil unless data
|
|
156
|
+
|
|
157
|
+
extract_per_minute(data)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# ============================================================
|
|
161
|
+
# Tier 6: Helicone (text LLM only — future-proof)
|
|
162
|
+
# ============================================================
|
|
163
|
+
|
|
164
|
+
def from_helicone(model_id)
|
|
165
|
+
data = Pricing::HeliconeAdapter.find_model(model_id)
|
|
166
|
+
return nil unless data
|
|
167
|
+
|
|
168
|
+
extract_per_minute(data)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ============================================================
|
|
172
|
+
# Tier 7: LLM Pricing AI (text LLM only — future-proof)
|
|
173
|
+
# ============================================================
|
|
174
|
+
|
|
175
|
+
def from_llmpricing(model_id)
|
|
176
|
+
data = Pricing::LLMPricingAdapter.find_model(model_id)
|
|
177
|
+
return nil unless data
|
|
178
|
+
|
|
179
|
+
extract_per_minute(data)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# ============================================================
|
|
183
|
+
# Price extraction
|
|
184
|
+
# ============================================================
|
|
185
|
+
|
|
186
|
+
def extract_per_minute(data)
|
|
187
|
+
# Per-second pricing (most common for transcription: whisper-1, etc.)
|
|
188
|
+
if data[:input_cost_per_second]
|
|
189
|
+
return (data[:input_cost_per_second] * 60).round(6)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Per-audio-token pricing (GPT-4o-transcribe models)
|
|
193
|
+
# ~25 audio tokens/second = 1500 tokens/minute
|
|
194
|
+
if data[:input_cost_per_audio_token]
|
|
195
|
+
return (data[:input_cost_per_audio_token] * 1500).round(6)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def litellm_transcription_models
|
|
202
|
+
data = Pricing::DataStore.litellm_data
|
|
203
|
+
return {} unless data.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
data.select do |key, value|
|
|
206
|
+
value.is_a?(Hash) && (
|
|
207
|
+
value["mode"] == "audio_transcription" ||
|
|
208
|
+
value["input_cost_per_second"] ||
|
|
209
|
+
key.to_s.match?(/whisper|transcri/i)
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def normalize_model_id(model_id)
|
|
215
|
+
model_id.to_s.downcase
|
|
216
|
+
.gsub(/[^a-z0-9._-]/, "-").squeeze("-")
|
|
217
|
+
.gsub(/^-|-$/, "")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def config
|
|
221
|
+
RubyLLM::Agents.configuration
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|