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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82355e2a179ddaf2f5003b2cbd972f373b2ca49cdcc2847535aec89fb18ed046
4
- data.tar.gz: '09656de02af43adafdfe2615d1bfcb67aee76602fd0699d0f739eda731f29d8d'
3
+ metadata.gz: 4cb913b70bd04950b91cc665ef9e2af84aa009618dffb7072e9d6b8ae389bc77
4
+ data.tar.gz: 479066023ed864ee78c317c37f64e92c8fb2b503215ac0280e097a14d9ba2ba7
5
5
  SHA512:
6
- metadata.gz: a5c8b20da41f0f73b8fdbffb809cecc726f1e7e6030d8351c5b994c58192b8d18da7693fa8fadec603f8dfb29ab7dd40907877600f58af185ab9d5542a884dcf
7
- data.tar.gz: b6c0c90038a87f2824ff52b0bedd901528e291748a18caff4fd2df403affd351bf7cdf05db3042e6daae05d602c672f90a9a97434e6f54f9834c337ebae1a607
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
- # 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",
@@ -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 && vs.style_value && vs.style_value > 0 && model["can_use_style"] != true
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
- # 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