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
@@ -8,10 +8,11 @@ module RubyLLM
8
8
  module Audio
9
9
  # Dynamic pricing resolution for text-to-speech models.
10
10
  #
11
- # Uses the same three-tier strategy as ImageGenerator::Pricing:
11
+ # Uses a four-tier pricing cascade:
12
12
  # 1. LiteLLM JSON (primary) - future-proof, auto-updating
13
13
  # 2. Configurable pricing table - user overrides via config.tts_model_pricing
14
- # 3. Hardcoded fallbacks - per-model defaults
14
+ # 3. ElevenLabs API - dynamic multiplier × base rate from /v1/models
15
+ # 4. Hardcoded fallbacks - per-model defaults
15
16
  #
16
17
  # All prices are per 1,000 characters.
17
18
  #
@@ -50,14 +51,22 @@ module RubyLLM
50
51
  # @param model_id [String] Model identifier
51
52
  # @return [Float] Cost per 1K characters in USD
52
53
  def cost_per_1k_characters(provider, model_id)
54
+ # Tier 1: LiteLLM
53
55
  if (litellm_price = from_litellm(model_id))
54
56
  return litellm_price
55
57
  end
56
58
 
59
+ # Tier 2: User config overrides
57
60
  if (config_price = from_config(model_id))
58
61
  return config_price
59
62
  end
60
63
 
64
+ # Tier 3: ElevenLabs API multiplier × base rate
65
+ if provider == :elevenlabs && (api_price = from_elevenlabs_api(model_id))
66
+ return api_price
67
+ end
68
+
69
+ # Tier 4: Hardcoded fallbacks
61
70
  fallback_price(provider, model_id)
62
71
  end
63
72
 
@@ -73,6 +82,7 @@ module RubyLLM
73
82
  {
74
83
  litellm: litellm_tts_models,
75
84
  configured: config.tts_model_pricing || {},
85
+ elevenlabs_api: elevenlabs_api_pricing,
76
86
  fallbacks: fallback_pricing_table
77
87
  }
78
88
  end
@@ -190,6 +200,19 @@ module RubyLLM
190
200
  end
191
201
  end
192
202
 
203
+ def elevenlabs_api_pricing
204
+ return {} unless defined?(ElevenLabs::ModelRegistry)
205
+
206
+ base = config.elevenlabs_base_cost_per_1k || 0.30
207
+ ElevenLabs::ModelRegistry.models.each_with_object({}) do |model, hash|
208
+ multiplier = model.dig("model_rates", "character_cost_multiplier") || 1.0
209
+ hash[model["model_id"]] = (base * multiplier).round(6)
210
+ end
211
+ rescue => e
212
+ warn "[RubyLLM::Agents] Failed to get ElevenLabs API pricing: #{e.message}"
213
+ {}
214
+ end
215
+
193
216
  # ============================================================
194
217
  # Tier 2: User configuration
195
218
  # ============================================================
@@ -207,7 +230,25 @@ module RubyLLM
207
230
  end
208
231
 
209
232
  # ============================================================
210
- # Tier 3: Hardcoded fallbacks
233
+ # Tier 3: ElevenLabs API (dynamic multiplier × base rate)
234
+ # ============================================================
235
+
236
+ def from_elevenlabs_api(model_id)
237
+ return nil unless defined?(ElevenLabs::ModelRegistry)
238
+
239
+ model = ElevenLabs::ModelRegistry.find(model_id)
240
+ return nil unless model
241
+
242
+ multiplier = model.dig("model_rates", "character_cost_multiplier") || 1.0
243
+ base = config.elevenlabs_base_cost_per_1k || 0.30
244
+ (base * multiplier).round(6)
245
+ rescue => e
246
+ warn "[RubyLLM::Agents] Failed to get ElevenLabs API pricing: #{e.message}"
247
+ nil
248
+ end
249
+
250
+ # ============================================================
251
+ # Tier 4: Hardcoded fallbacks
211
252
  # ============================================================
212
253
 
213
254
  def fallback_price(provider, model_id)
@@ -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
- # Get duration in minutes
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
- # Estimate based on model and duration
634
+ # Delegate to TranscriptionPricing (2-tier: LiteLLM + user config)
629
635
  model = raw_result[:model].to_s
630
- price_per_minute = case model
631
- when /whisper-1/
632
- 0.006
633
- when /gpt-4o-transcribe/
634
- 0.01
635
- when /gpt-4o-mini-transcribe/
636
- 0.005
637
- else
638
- 0.006 # Default to whisper pricing
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
- duration_minutes * price_per_minute
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
@@ -453,7 +453,20 @@ module RubyLLM
453
453
  :root_namespace,
454
454
  :tool_result_max_length,
455
455
  :redaction,
456
- :persist_audio_data
456
+ :persist_audio_data,
457
+ :elevenlabs_base_cost_per_1k,
458
+ :elevenlabs_models_cache_ttl,
459
+ :transcription_model_pricing,
460
+ :default_transcription_cost,
461
+ :pricing_cache_ttl,
462
+ :portkey_pricing_enabled,
463
+ :portkey_pricing_url,
464
+ :openrouter_pricing_enabled,
465
+ :openrouter_pricing_url,
466
+ :helicone_pricing_enabled,
467
+ :helicone_pricing_url,
468
+ :llmpricing_enabled,
469
+ :llmpricing_url
457
470
 
458
471
  # Attributes with validation (readers only, custom setters below)
459
472
  attr_reader :default_temperature,
@@ -672,6 +685,19 @@ module RubyLLM
672
685
  # Transcription defaults
673
686
  @default_transcription_model = "whisper-1"
674
687
  @track_transcriptions = true
688
+ @transcription_model_pricing = {}
689
+ @default_transcription_cost = nil # nil = no default, will trigger warning
690
+
691
+ # Multi-source pricing defaults
692
+ @pricing_cache_ttl = nil # nil = use DataStore default (24h)
693
+ @portkey_pricing_enabled = true
694
+ @portkey_pricing_url = nil # nil = use default
695
+ @openrouter_pricing_enabled = true
696
+ @openrouter_pricing_url = nil
697
+ @helicone_pricing_enabled = true
698
+ @helicone_pricing_url = nil
699
+ @llmpricing_enabled = true
700
+ @llmpricing_url = nil
675
701
 
676
702
  # TTS/Speech defaults
677
703
  @default_tts_provider = :openai
@@ -738,6 +764,11 @@ module RubyLLM
738
764
 
739
765
  # Audio data persistence (disabled by default — base64 audio can be large)
740
766
  @persist_audio_data = false
767
+
768
+ # ElevenLabs dynamic pricing: base cost per 1K characters (Pro plan overage rate)
769
+ @elevenlabs_base_cost_per_1k = 0.30
770
+ # ElevenLabs models cache TTL in seconds (6 hours)
771
+ @elevenlabs_models_cache_ttl = 21_600
741
772
  end
742
773
 
743
774
  # Returns the configured cache store, falling back to Rails.cache
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.3.0"
7
+ VERSION = "3.5.0"
8
8
  end
9
9
  end