ruby_llm-agents 3.0.0 → 3.2.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +16 -14
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +20 -20
  5. data/app/controllers/ruby_llm/agents/executions_controller.rb +5 -7
  6. data/app/helpers/ruby_llm/agents/application_helper.rb +57 -58
  7. data/app/models/ruby_llm/agents/execution/analytics.rb +27 -27
  8. data/app/models/ruby_llm/agents/execution/scopes.rb +4 -6
  9. data/app/models/ruby_llm/agents/execution.rb +26 -26
  10. data/app/models/ruby_llm/agents/tenant/budgetable.rb +16 -10
  11. data/app/models/ruby_llm/agents/tenant/resettable.rb +12 -12
  12. data/app/models/ruby_llm/agents/tenant/trackable.rb +7 -7
  13. data/app/services/ruby_llm/agents/agent_registry.rb +6 -6
  14. data/app/views/layouts/ruby_llm/agents/application.html.erb +142 -11
  15. data/app/views/ruby_llm/agents/agents/show.html.erb +10 -10
  16. data/app/views/ruby_llm/agents/dashboard/index.html.erb +10 -10
  17. data/app/views/ruby_llm/agents/executions/show.html.erb +13 -0
  18. data/lib/generators/ruby_llm_agents/agent_generator.rb +4 -4
  19. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +6 -6
  20. data/lib/generators/ruby_llm_agents/embedder_generator.rb +4 -4
  21. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -7
  22. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +4 -4
  23. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +6 -6
  24. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +9 -9
  25. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +6 -6
  26. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +4 -4
  27. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +4 -4
  28. data/lib/generators/ruby_llm_agents/install_generator.rb +3 -3
  29. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +4 -4
  30. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +2 -2
  31. data/lib/generators/ruby_llm_agents/restructure_generator.rb +13 -13
  32. data/lib/generators/ruby_llm_agents/speaker_generator.rb +6 -6
  33. data/lib/generators/ruby_llm_agents/templates/add_assistant_prompt_migration.rb.tt +9 -0
  34. data/lib/generators/ruby_llm_agents/templates/split_execution_details_migration.rb.tt +2 -1
  35. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +4 -4
  36. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +22 -3
  37. data/lib/ruby_llm/agents/audio/speaker.rb +40 -31
  38. data/lib/ruby_llm/agents/audio/speech_client.rb +328 -0
  39. data/lib/ruby_llm/agents/audio/speech_pricing.rb +273 -0
  40. data/lib/ruby_llm/agents/audio/transcriber.rb +33 -33
  41. data/lib/ruby_llm/agents/base_agent.rb +16 -15
  42. data/lib/ruby_llm/agents/core/base/callbacks.rb +3 -3
  43. data/lib/ruby_llm/agents/core/configuration.rb +86 -73
  44. data/lib/ruby_llm/agents/core/errors.rb +27 -2
  45. data/lib/ruby_llm/agents/core/instrumentation.rb +101 -65
  46. data/lib/ruby_llm/agents/core/llm_tenant.rb +7 -7
  47. data/lib/ruby_llm/agents/core/version.rb +1 -1
  48. data/lib/ruby_llm/agents/dsl/base.rb +3 -3
  49. data/lib/ruby_llm/agents/dsl/reliability.rb +9 -9
  50. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +1 -1
  51. data/lib/ruby_llm/agents/image/analyzer/execution.rb +4 -4
  52. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +1 -1
  53. data/lib/ruby_llm/agents/image/background_remover/execution.rb +3 -3
  54. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +8 -8
  55. data/lib/ruby_llm/agents/image/editor/execution.rb +1 -1
  56. data/lib/ruby_llm/agents/image/generator/pricing.rb +9 -10
  57. data/lib/ruby_llm/agents/image/generator.rb +6 -6
  58. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +6 -6
  59. data/lib/ruby_llm/agents/image/pipeline/execution.rb +9 -9
  60. data/lib/ruby_llm/agents/image/pipeline.rb +1 -1
  61. data/lib/ruby_llm/agents/image/transformer/execution.rb +1 -1
  62. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +1 -1
  63. data/lib/ruby_llm/agents/image/upscaler/execution.rb +3 -5
  64. data/lib/ruby_llm/agents/image/variator/execution.rb +1 -1
  65. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +4 -4
  66. data/lib/ruby_llm/agents/infrastructure/attempt_tracker.rb +4 -4
  67. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +9 -9
  68. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +3 -3
  69. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +1 -1
  70. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +17 -17
  71. data/lib/ruby_llm/agents/infrastructure/circuit_breaker.rb +1 -0
  72. data/lib/ruby_llm/agents/infrastructure/execution_logger_job.rb +1 -1
  73. data/lib/ruby_llm/agents/infrastructure/reliability.rb +6 -6
  74. data/lib/ruby_llm/agents/pipeline/builder.rb +11 -11
  75. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +3 -3
  76. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +4 -4
  77. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +62 -21
  78. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +2 -3
  79. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +82 -4
  80. data/lib/ruby_llm/agents/results/background_removal_result.rb +6 -6
  81. data/lib/ruby_llm/agents/results/embedding_result.rb +15 -15
  82. data/lib/ruby_llm/agents/results/image_analysis_result.rb +7 -7
  83. data/lib/ruby_llm/agents/results/image_edit_result.rb +4 -4
  84. data/lib/ruby_llm/agents/results/image_generation_result.rb +5 -5
  85. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +4 -4
  86. data/lib/ruby_llm/agents/results/image_transform_result.rb +4 -4
  87. data/lib/ruby_llm/agents/results/image_upscale_result.rb +5 -5
  88. data/lib/ruby_llm/agents/results/image_variation_result.rb +4 -4
  89. data/lib/ruby_llm/agents/results/transcription_result.rb +1 -1
  90. data/lib/ruby_llm/agents/text/embedder.rb +13 -13
  91. metadata +4 -1
@@ -0,0 +1,328 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module RubyLLM
7
+ module Agents
8
+ module Audio
9
+ # Direct HTTP client for text-to-speech APIs.
10
+ #
11
+ # Supports OpenAI and ElevenLabs providers, bypassing the need for
12
+ # a RubyLLM.speak() method that does not exist in the base gem.
13
+ #
14
+ # @example OpenAI
15
+ # client = SpeechClient.new(provider: :openai)
16
+ # response = client.speak("Hello", model: "tts-1", voice: "nova")
17
+ # response.audio # => binary audio data
18
+ #
19
+ # @example ElevenLabs
20
+ # client = SpeechClient.new(provider: :elevenlabs)
21
+ # response = client.speak("Hello",
22
+ # model: "eleven_v3",
23
+ # voice: "Rachel",
24
+ # voice_id: "21m00Tcm4TlvDq8ikWAM",
25
+ # voice_settings: { stability: 0.5, similarity_boost: 0.75 }
26
+ # )
27
+ #
28
+ class SpeechClient
29
+ SUPPORTED_PROVIDERS = %i[openai elevenlabs].freeze
30
+
31
+ Response = Struct.new(:audio, :format, :model, :voice, keyword_init: true) do
32
+ def duration
33
+ nil
34
+ end
35
+
36
+ def cost
37
+ nil
38
+ end
39
+ end
40
+
41
+ StreamChunk = Struct.new(:audio, keyword_init: true)
42
+
43
+ # @param provider [Symbol] :openai or :elevenlabs
44
+ # @raise [UnsupportedProviderError] if provider is not supported
45
+ def initialize(provider:)
46
+ validate_provider!(provider)
47
+ @provider = provider
48
+ end
49
+
50
+ # Synthesize speech (non-streaming)
51
+ #
52
+ # @param text [String] text to convert
53
+ # @param model [String] model identifier
54
+ # @param voice [String] voice name
55
+ # @param voice_id [String, nil] voice ID (required for ElevenLabs)
56
+ # @param speed [Float, nil] speed multiplier
57
+ # @param response_format [String] output format
58
+ # @param voice_settings [Hash, nil] ElevenLabs voice settings
59
+ # @return [Response]
60
+ def speak(text, model:, voice:, voice_id: nil, speed: nil,
61
+ response_format: "mp3", voice_settings: nil)
62
+ case @provider
63
+ when :openai
64
+ openai_speak(text, model: model, voice: voice_id || voice,
65
+ speed: speed, response_format: response_format)
66
+ when :elevenlabs
67
+ elevenlabs_speak(text, model: model, voice_id: voice_id || voice,
68
+ speed: speed, response_format: response_format,
69
+ voice_settings: voice_settings)
70
+ end
71
+ end
72
+
73
+ # Synthesize speech with streaming
74
+ #
75
+ # @param text [String] text to convert
76
+ # @param model [String] model identifier
77
+ # @param voice [String] voice name
78
+ # @param voice_id [String, nil] voice ID
79
+ # @param speed [Float, nil] speed multiplier
80
+ # @param response_format [String] output format
81
+ # @param voice_settings [Hash, nil] ElevenLabs voice settings
82
+ # @yield [StreamChunk] each audio chunk as it arrives
83
+ # @return [Response]
84
+ def speak_streaming(text, model:, voice:, voice_id: nil, speed: nil,
85
+ response_format: "mp3", voice_settings: nil, &block)
86
+ case @provider
87
+ when :openai
88
+ openai_speak_streaming(text, model: model, voice: voice_id || voice,
89
+ speed: speed, response_format: response_format,
90
+ &block)
91
+ when :elevenlabs
92
+ elevenlabs_speak_streaming(text, model: model,
93
+ voice_id: voice_id || voice,
94
+ speed: speed,
95
+ response_format: response_format,
96
+ voice_settings: voice_settings, &block)
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ # ============================================================
103
+ # Provider validation
104
+ # ============================================================
105
+
106
+ def validate_provider!(provider)
107
+ return if SUPPORTED_PROVIDERS.include?(provider)
108
+
109
+ raise UnsupportedProviderError.new(
110
+ "Provider :#{provider} is not yet supported for text-to-speech. " \
111
+ "Supported providers: #{SUPPORTED_PROVIDERS.map { |p| ":#{p}" }.join(", ")}.",
112
+ provider: provider
113
+ )
114
+ end
115
+
116
+ # ============================================================
117
+ # OpenAI implementation
118
+ # ============================================================
119
+
120
+ def openai_speak(text, model:, voice:, speed:, response_format:)
121
+ body = openai_request_body(text, model: model, voice: voice,
122
+ speed: speed, response_format: response_format)
123
+
124
+ response = openai_connection.post("/v1/audio/speech") do |req|
125
+ req.headers["Content-Type"] = "application/json"
126
+ req.body = body.to_json
127
+ end
128
+
129
+ handle_error_response!(response) unless response.success?
130
+
131
+ Response.new(
132
+ audio: response.body,
133
+ format: response_format.to_sym,
134
+ model: model,
135
+ voice: voice
136
+ )
137
+ end
138
+
139
+ def openai_speak_streaming(text, model:, voice:, speed:,
140
+ response_format:, &block)
141
+ body = openai_request_body(text, model: model, voice: voice,
142
+ speed: speed, response_format: response_format)
143
+ chunks = []
144
+
145
+ openai_connection.post("/v1/audio/speech") do |req|
146
+ req.headers["Content-Type"] = "application/json"
147
+ req.body = body.to_json
148
+ req.options.on_data = proc do |chunk, _size, env|
149
+ if env.status == 200
150
+ chunk_obj = StreamChunk.new(audio: chunk)
151
+ chunks << chunk
152
+ block&.call(chunk_obj)
153
+ end
154
+ end
155
+ end
156
+
157
+ Response.new(
158
+ audio: chunks.join,
159
+ format: response_format.to_sym,
160
+ model: model,
161
+ voice: voice
162
+ )
163
+ end
164
+
165
+ def openai_request_body(text, model:, voice:, speed:, response_format:)
166
+ body = {
167
+ model: model,
168
+ input: text,
169
+ voice: voice,
170
+ response_format: response_format.to_s
171
+ }
172
+ body[:speed] = speed if speed && (speed - 1.0).abs > Float::EPSILON
173
+ body
174
+ end
175
+
176
+ def openai_connection
177
+ @openai_connection ||= Faraday.new(url: openai_api_base) do |f|
178
+ f.headers["Authorization"] = "Bearer #{openai_api_key}"
179
+ f.adapter Faraday.default_adapter
180
+ f.options.timeout = 120
181
+ f.options.open_timeout = 30
182
+ end
183
+ end
184
+
185
+ def openai_api_key
186
+ key = RubyLLM.config.openai_api_key
187
+ unless key
188
+ raise ConfigurationError,
189
+ "OpenAI API key is required for text-to-speech. " \
190
+ "Set it via: RubyLLM.configure { |c| c.openai_api_key = 'sk-...' }"
191
+ end
192
+ key
193
+ end
194
+
195
+ def openai_api_base
196
+ base = RubyLLM.config.openai_api_base
197
+ (base && !base.empty?) ? base : "https://api.openai.com"
198
+ end
199
+
200
+ # ============================================================
201
+ # ElevenLabs implementation
202
+ # ============================================================
203
+
204
+ def elevenlabs_speak(text, model:, voice_id:, speed:,
205
+ response_format:, voice_settings:)
206
+ path = "/v1/text-to-speech/#{voice_id}"
207
+ body = elevenlabs_request_body(text, model: model, speed: speed,
208
+ voice_settings: voice_settings)
209
+ format_param = elevenlabs_output_format(response_format)
210
+
211
+ response = elevenlabs_connection.post(path) do |req|
212
+ req.headers["Content-Type"] = "application/json"
213
+ req.params["output_format"] = format_param
214
+ req.body = body.to_json
215
+ end
216
+
217
+ handle_error_response!(response) unless response.success?
218
+
219
+ Response.new(
220
+ audio: response.body,
221
+ format: response_format.to_sym,
222
+ model: model,
223
+ voice: voice_id
224
+ )
225
+ end
226
+
227
+ def elevenlabs_speak_streaming(text, model:, voice_id:, speed:,
228
+ response_format:, voice_settings:, &block)
229
+ path = "/v1/text-to-speech/#{voice_id}/stream"
230
+ body = elevenlabs_request_body(text, model: model, speed: speed,
231
+ voice_settings: voice_settings)
232
+ format_param = elevenlabs_output_format(response_format)
233
+ chunks = []
234
+
235
+ elevenlabs_connection.post(path) do |req|
236
+ req.headers["Content-Type"] = "application/json"
237
+ req.params["output_format"] = format_param
238
+ req.body = body.to_json
239
+ req.options.on_data = proc do |chunk, _size, env|
240
+ if env.status == 200
241
+ chunk_obj = StreamChunk.new(audio: chunk)
242
+ chunks << chunk
243
+ block&.call(chunk_obj)
244
+ end
245
+ end
246
+ end
247
+
248
+ Response.new(
249
+ audio: chunks.join,
250
+ format: response_format.to_sym,
251
+ model: model,
252
+ voice: voice_id
253
+ )
254
+ end
255
+
256
+ def elevenlabs_request_body(text, model:, speed:, voice_settings:)
257
+ body = {
258
+ text: text,
259
+ model_id: model
260
+ }
261
+
262
+ vs = voice_settings&.dup || {}
263
+ vs[:speed] = speed if speed && (speed - 1.0).abs > Float::EPSILON
264
+ body[:voice_settings] = vs unless vs.empty?
265
+
266
+ body
267
+ end
268
+
269
+ ELEVENLABS_FORMAT_MAP = {
270
+ "mp3" => "mp3_44100_128",
271
+ "pcm" => "pcm_44100",
272
+ "ulaw" => "ulaw_8000"
273
+ }.freeze
274
+
275
+ def elevenlabs_output_format(format)
276
+ ELEVENLABS_FORMAT_MAP[format.to_s] || "mp3_44100_128"
277
+ end
278
+
279
+ def elevenlabs_connection
280
+ @elevenlabs_connection ||= Faraday.new(url: elevenlabs_api_base) do |f|
281
+ f.headers["xi-api-key"] = elevenlabs_api_key
282
+ f.adapter Faraday.default_adapter
283
+ f.options.timeout = 120
284
+ f.options.open_timeout = 30
285
+ end
286
+ end
287
+
288
+ def elevenlabs_api_key
289
+ key = RubyLLM::Agents.configuration.elevenlabs_api_key
290
+ unless key
291
+ raise ConfigurationError,
292
+ "ElevenLabs API key is required for text-to-speech. " \
293
+ "Set it via: RubyLLM::Agents.configure { |c| c.elevenlabs_api_key = 'xi-...' }"
294
+ end
295
+ key
296
+ end
297
+
298
+ def elevenlabs_api_base
299
+ base = RubyLLM::Agents.configuration.elevenlabs_api_base
300
+ (base && !base.empty?) ? base : "https://api.elevenlabs.io"
301
+ end
302
+
303
+ # ============================================================
304
+ # Shared error handling
305
+ # ============================================================
306
+
307
+ def handle_error_response!(response)
308
+ raise SpeechApiError.new(
309
+ "TTS API request failed (HTTP #{response.status}): #{error_message_from(response)}",
310
+ status: response.status,
311
+ response_body: response.body
312
+ )
313
+ end
314
+
315
+ def error_message_from(response)
316
+ parsed = JSON.parse(response.body)
317
+ if parsed.is_a?(Hash)
318
+ parsed.dig("error", "message") || parsed["detail"] || parsed["error"] || response.body
319
+ else
320
+ response.body
321
+ end
322
+ rescue JSON::ParserError
323
+ response.body.to_s[0, 200]
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module RubyLLM
7
+ module Agents
8
+ module Audio
9
+ # Dynamic pricing resolution for text-to-speech models.
10
+ #
11
+ # Uses the same three-tier strategy as ImageGenerator::Pricing:
12
+ # 1. LiteLLM JSON (primary) - future-proof, auto-updating
13
+ # 2. Configurable pricing table - user overrides via config.tts_model_pricing
14
+ # 3. Hardcoded fallbacks - per-model defaults
15
+ #
16
+ # All prices are per 1,000 characters.
17
+ #
18
+ # @example Get cost for a speech operation
19
+ # SpeechPricing.calculate_cost(provider: :openai, model_id: "tts-1", characters: 5000)
20
+ # # => 0.075
21
+ #
22
+ # @example User-configured pricing
23
+ # RubyLLM::Agents.configure do |c|
24
+ # c.tts_model_pricing = {
25
+ # "eleven_v3" => 0.24,
26
+ # "tts-1" => 0.015
27
+ # }
28
+ # end
29
+ #
30
+ module SpeechPricing
31
+ extend self
32
+
33
+ LITELLM_PRICING_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
34
+ DEFAULT_CACHE_TTL = 24 * 60 * 60 # 24 hours
35
+
36
+ # Calculate total cost for a speech operation
37
+ #
38
+ # @param provider [Symbol] :openai or :elevenlabs
39
+ # @param model_id [String] The model identifier
40
+ # @param characters [Integer] Number of characters synthesized
41
+ # @return [Float] Total cost in USD
42
+ def calculate_cost(provider:, model_id:, characters:)
43
+ price_per_1k = cost_per_1k_characters(provider, model_id)
44
+ ((characters / 1000.0) * price_per_1k).round(6)
45
+ end
46
+
47
+ # Get cost per 1,000 characters for a model
48
+ #
49
+ # @param provider [Symbol] Provider identifier
50
+ # @param model_id [String] Model identifier
51
+ # @return [Float] Cost per 1K characters in USD
52
+ def cost_per_1k_characters(provider, model_id)
53
+ if (litellm_price = from_litellm(model_id))
54
+ return litellm_price
55
+ end
56
+
57
+ if (config_price = from_config(model_id))
58
+ return config_price
59
+ end
60
+
61
+ fallback_price(provider, model_id)
62
+ end
63
+
64
+ # Force refresh of cached LiteLLM data
65
+ def refresh!
66
+ @litellm_data = nil
67
+ @litellm_fetched_at = nil
68
+ litellm_data
69
+ end
70
+
71
+ # Expose all known pricing for debugging/dashboard
72
+ def all_pricing
73
+ {
74
+ litellm: litellm_tts_models,
75
+ configured: config.tts_model_pricing || {},
76
+ fallbacks: fallback_pricing_table
77
+ }
78
+ end
79
+
80
+ private
81
+
82
+ # ============================================================
83
+ # Tier 1: LiteLLM
84
+ # ============================================================
85
+
86
+ def from_litellm(model_id)
87
+ data = litellm_data
88
+ return nil unless data
89
+
90
+ model_data = find_litellm_model(data, model_id)
91
+ return nil unless model_data
92
+
93
+ extract_litellm_tts_price(model_data)
94
+ end
95
+
96
+ def find_litellm_model(data, model_id)
97
+ normalized = normalize_model_id(model_id)
98
+
99
+ candidates = [
100
+ model_id,
101
+ normalized,
102
+ "tts/#{model_id}",
103
+ "openai/#{model_id}",
104
+ "elevenlabs/#{model_id}"
105
+ ]
106
+
107
+ candidates.each do |key|
108
+ return data[key] if data[key]
109
+ end
110
+
111
+ data.find do |key, _|
112
+ key.to_s.downcase.include?(normalized.downcase)
113
+ end&.last
114
+ end
115
+
116
+ def extract_litellm_tts_price(model_data)
117
+ if model_data["input_cost_per_character"]
118
+ return model_data["input_cost_per_character"] * 1000
119
+ end
120
+
121
+ if model_data["output_cost_per_character"]
122
+ return model_data["output_cost_per_character"] * 1000
123
+ end
124
+
125
+ if model_data["output_cost_per_audio_token"]
126
+ return model_data["output_cost_per_audio_token"] * 250
127
+ end
128
+
129
+ nil
130
+ end
131
+
132
+ def litellm_data
133
+ return @litellm_data if @litellm_data && !cache_expired?
134
+
135
+ @litellm_data = fetch_litellm_data
136
+ @litellm_fetched_at = Time.now
137
+ @litellm_data
138
+ end
139
+
140
+ def fetch_litellm_data
141
+ if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
142
+ Rails.cache.fetch("litellm_tts_pricing_data", expires_in: cache_ttl) do
143
+ fetch_from_url
144
+ end
145
+ else
146
+ fetch_from_url
147
+ end
148
+ rescue => e
149
+ warn "[RubyLLM::Agents] Failed to fetch LiteLLM TTS pricing: #{e.message}"
150
+ {}
151
+ end
152
+
153
+ def fetch_from_url
154
+ uri = URI(config.litellm_pricing_url || LITELLM_PRICING_URL)
155
+ http = Net::HTTP.new(uri.host, uri.port)
156
+ http.use_ssl = uri.scheme == "https"
157
+ http.open_timeout = 5
158
+ http.read_timeout = 10
159
+
160
+ request = Net::HTTP::Get.new(uri)
161
+ response = http.request(request)
162
+
163
+ if response.is_a?(Net::HTTPSuccess)
164
+ JSON.parse(response.body)
165
+ else
166
+ {}
167
+ end
168
+ rescue => e
169
+ warn "[RubyLLM::Agents] HTTP error fetching LiteLLM pricing: #{e.message}"
170
+ {}
171
+ end
172
+
173
+ def cache_expired?
174
+ return true unless @litellm_fetched_at
175
+ Time.now - @litellm_fetched_at > cache_ttl
176
+ end
177
+
178
+ def cache_ttl
179
+ ttl = config.litellm_pricing_cache_ttl
180
+ return DEFAULT_CACHE_TTL unless ttl
181
+ ttl.respond_to?(:to_i) ? ttl.to_i : ttl
182
+ end
183
+
184
+ def litellm_tts_models
185
+ litellm_data.select do |key, value|
186
+ value.is_a?(Hash) && (
187
+ value["input_cost_per_character"] ||
188
+ key.to_s.match?(/tts|speech|eleven/i)
189
+ )
190
+ end
191
+ end
192
+
193
+ # ============================================================
194
+ # Tier 2: User configuration
195
+ # ============================================================
196
+
197
+ def from_config(model_id)
198
+ table = config.tts_model_pricing
199
+ return nil unless table.is_a?(Hash) && !table.empty?
200
+
201
+ normalized = normalize_model_id(model_id)
202
+
203
+ price = table[model_id] || table[normalized] ||
204
+ table[model_id.to_sym] || table[normalized.to_sym]
205
+
206
+ price if price.is_a?(Numeric)
207
+ end
208
+
209
+ # ============================================================
210
+ # Tier 3: Hardcoded fallbacks
211
+ # ============================================================
212
+
213
+ def fallback_price(provider, model_id)
214
+ normalized = normalize_model_id(model_id)
215
+
216
+ case provider
217
+ when :openai
218
+ openai_fallback_price(normalized)
219
+ when :elevenlabs
220
+ elevenlabs_fallback_price(normalized)
221
+ else
222
+ config.default_tts_cost || 0.015
223
+ end
224
+ end
225
+
226
+ def openai_fallback_price(model_id)
227
+ case model_id
228
+ when /tts-1-hd/ then 0.030
229
+ when /tts-1/ then 0.015
230
+ else 0.015
231
+ end
232
+ end
233
+
234
+ def elevenlabs_fallback_price(model_id)
235
+ case model_id
236
+ when /eleven_flash_v2/ then 0.15
237
+ when /eleven_turbo_v2/ then 0.15
238
+ when /eleven_v3/ then 0.30
239
+ when /eleven_multilingual_v2/ then 0.30
240
+ when /eleven_multilingual_v1/ then 0.30
241
+ when /eleven_monolingual_v1/ then 0.30
242
+ else 0.30
243
+ end
244
+ end
245
+
246
+ def fallback_pricing_table
247
+ {
248
+ "tts-1" => 0.015,
249
+ "tts-1-hd" => 0.030,
250
+ "eleven_monolingual_v1" => 0.30,
251
+ "eleven_multilingual_v1" => 0.30,
252
+ "eleven_multilingual_v2" => 0.30,
253
+ "eleven_turbo_v2" => 0.15,
254
+ "eleven_flash_v2" => 0.15,
255
+ "eleven_turbo_v2_5" => 0.15,
256
+ "eleven_flash_v2_5" => 0.15,
257
+ "eleven_v3" => 0.30
258
+ }
259
+ end
260
+
261
+ def normalize_model_id(model_id)
262
+ model_id.to_s.downcase
263
+ .gsub(/[^a-z0-9._-]/, "-").squeeze("-")
264
+ .gsub(/^-|-$/, "")
265
+ end
266
+
267
+ def config
268
+ RubyLLM::Agents.configuration
269
+ end
270
+ end
271
+ end
272
+ end
273
+ end