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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +16 -14
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +20 -20
- data/app/controllers/ruby_llm/agents/executions_controller.rb +5 -7
- data/app/helpers/ruby_llm/agents/application_helper.rb +57 -58
- data/app/models/ruby_llm/agents/execution/analytics.rb +27 -27
- data/app/models/ruby_llm/agents/execution/scopes.rb +4 -6
- data/app/models/ruby_llm/agents/execution.rb +26 -26
- data/app/models/ruby_llm/agents/tenant/budgetable.rb +16 -10
- data/app/models/ruby_llm/agents/tenant/resettable.rb +12 -12
- data/app/models/ruby_llm/agents/tenant/trackable.rb +7 -7
- data/app/services/ruby_llm/agents/agent_registry.rb +6 -6
- data/app/views/layouts/ruby_llm/agents/application.html.erb +142 -11
- data/app/views/ruby_llm/agents/agents/show.html.erb +10 -10
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +10 -10
- data/app/views/ruby_llm/agents/executions/show.html.erb +13 -0
- data/lib/generators/ruby_llm_agents/agent_generator.rb +4 -4
- data/lib/generators/ruby_llm_agents/background_remover_generator.rb +6 -6
- data/lib/generators/ruby_llm_agents/embedder_generator.rb +4 -4
- data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -7
- data/lib/generators/ruby_llm_agents/image_editor_generator.rb +4 -4
- data/lib/generators/ruby_llm_agents/image_generator_generator.rb +6 -6
- data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +9 -9
- data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +6 -6
- data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +4 -4
- data/lib/generators/ruby_llm_agents/image_variator_generator.rb +4 -4
- data/lib/generators/ruby_llm_agents/install_generator.rb +3 -3
- data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +4 -4
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +2 -2
- data/lib/generators/ruby_llm_agents/restructure_generator.rb +13 -13
- data/lib/generators/ruby_llm_agents/speaker_generator.rb +6 -6
- data/lib/generators/ruby_llm_agents/templates/add_assistant_prompt_migration.rb.tt +9 -0
- data/lib/generators/ruby_llm_agents/templates/split_execution_details_migration.rb.tt +2 -1
- data/lib/generators/ruby_llm_agents/transcriber_generator.rb +4 -4
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +22 -3
- data/lib/ruby_llm/agents/audio/speaker.rb +40 -31
- data/lib/ruby_llm/agents/audio/speech_client.rb +328 -0
- data/lib/ruby_llm/agents/audio/speech_pricing.rb +273 -0
- data/lib/ruby_llm/agents/audio/transcriber.rb +33 -33
- data/lib/ruby_llm/agents/base_agent.rb +16 -15
- data/lib/ruby_llm/agents/core/base/callbacks.rb +3 -3
- data/lib/ruby_llm/agents/core/configuration.rb +86 -73
- data/lib/ruby_llm/agents/core/errors.rb +27 -2
- data/lib/ruby_llm/agents/core/instrumentation.rb +101 -65
- data/lib/ruby_llm/agents/core/llm_tenant.rb +7 -7
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/dsl/base.rb +3 -3
- data/lib/ruby_llm/agents/dsl/reliability.rb +9 -9
- data/lib/ruby_llm/agents/image/analyzer/dsl.rb +1 -1
- data/lib/ruby_llm/agents/image/analyzer/execution.rb +4 -4
- data/lib/ruby_llm/agents/image/background_remover/dsl.rb +1 -1
- data/lib/ruby_llm/agents/image/background_remover/execution.rb +3 -3
- data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +8 -8
- data/lib/ruby_llm/agents/image/editor/execution.rb +1 -1
- data/lib/ruby_llm/agents/image/generator/pricing.rb +9 -10
- data/lib/ruby_llm/agents/image/generator.rb +6 -6
- data/lib/ruby_llm/agents/image/pipeline/dsl.rb +6 -6
- data/lib/ruby_llm/agents/image/pipeline/execution.rb +9 -9
- data/lib/ruby_llm/agents/image/pipeline.rb +1 -1
- data/lib/ruby_llm/agents/image/transformer/execution.rb +1 -1
- data/lib/ruby_llm/agents/image/upscaler/dsl.rb +1 -1
- data/lib/ruby_llm/agents/image/upscaler/execution.rb +3 -5
- data/lib/ruby_llm/agents/image/variator/execution.rb +1 -1
- data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +4 -4
- data/lib/ruby_llm/agents/infrastructure/attempt_tracker.rb +4 -4
- data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +9 -9
- data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +3 -3
- data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +1 -1
- data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +17 -17
- data/lib/ruby_llm/agents/infrastructure/circuit_breaker.rb +1 -0
- data/lib/ruby_llm/agents/infrastructure/execution_logger_job.rb +1 -1
- data/lib/ruby_llm/agents/infrastructure/reliability.rb +6 -6
- data/lib/ruby_llm/agents/pipeline/builder.rb +11 -11
- data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +3 -3
- data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +4 -4
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +62 -21
- data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +2 -3
- data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +82 -4
- data/lib/ruby_llm/agents/results/background_removal_result.rb +6 -6
- data/lib/ruby_llm/agents/results/embedding_result.rb +15 -15
- data/lib/ruby_llm/agents/results/image_analysis_result.rb +7 -7
- data/lib/ruby_llm/agents/results/image_edit_result.rb +4 -4
- data/lib/ruby_llm/agents/results/image_generation_result.rb +5 -5
- data/lib/ruby_llm/agents/results/image_pipeline_result.rb +4 -4
- data/lib/ruby_llm/agents/results/image_transform_result.rb +4 -4
- data/lib/ruby_llm/agents/results/image_upscale_result.rb +5 -5
- data/lib/ruby_llm/agents/results/image_variation_result.rb +4 -4
- data/lib/ruby_llm/agents/results/transcription_result.rb +1 -1
- data/lib/ruby_llm/agents/text/embedder.rb +13 -13
- 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
|