ruby_llm-agents 1.0.0 → 1.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/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
- data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
- data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
- data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
- data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
- data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
- data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
- data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
- data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
- data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
- data/app/models/ruby_llm/agents/execution.rb +50 -14
- data/app/models/ruby_llm/agents/tenant/budgetable.rb +277 -0
- data/app/models/ruby_llm/agents/tenant/configurable.rb +135 -0
- data/app/models/ruby_llm/agents/tenant/trackable.rb +310 -0
- data/app/models/ruby_llm/agents/tenant.rb +146 -0
- data/app/models/ruby_llm/agents/tenant_budget.rb +12 -253
- data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
- data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
- data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
- data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
- data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
- data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
- data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
- data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
- data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
- data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
- data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
- data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
- data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
- data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
- data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
- data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
- data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
- data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
- data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
- data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
- data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
- data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
- data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
- data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
- data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
- data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
- data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
- data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
- data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
- data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
- data/config/routes.rb +1 -1
- data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
- data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
- data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
- data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
- data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
- data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +42 -22
- data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
- data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
- data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +13 -2
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
- data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
- data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
- data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
- data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
- data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
- data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
- data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
- data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +11 -0
- data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +72 -0
- data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
- data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
- data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
- data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
- data/lib/generators/ruby_llm_agents/templates/rename_tenant_budgets_to_tenants_migration.rb.tt +34 -0
- data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
- data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
- data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
- data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
- data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
- data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
- data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
- data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
- data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
- data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +26 -0
- data/lib/ruby_llm/agents/core/configuration.rb +55 -43
- data/lib/ruby_llm/agents/core/llm_tenant.rb +60 -60
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
- data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +4 -2
- data/lib/ruby_llm/agents/pipeline.rb +69 -0
- data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
- data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
- data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
- data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
- data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
- data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
- data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
- data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
- data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
- data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
- data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
- data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
- data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
- data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
- data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
- data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
- data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
- data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
- data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
- data/lib/ruby_llm/agents/workflow/result.rb +202 -0
- data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
- data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
- metadata +43 -6
- data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
- data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
- data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
- data/lib/ruby_llm/agents/workflow/router.rb +0 -429
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RubyLlmAgents
|
|
7
|
+
# Migration generator for moving from old directory structure to new
|
|
8
|
+
#
|
|
9
|
+
# Migrates from:
|
|
10
|
+
# app/{root}/agents/
|
|
11
|
+
# app/{root}/image/generators/
|
|
12
|
+
# app/{root}/audio/speakers/
|
|
13
|
+
# app/{root}/text/embedders/
|
|
14
|
+
# app/{root}/workflows/
|
|
15
|
+
#
|
|
16
|
+
# To:
|
|
17
|
+
# app/agents/
|
|
18
|
+
# app/agents/images/
|
|
19
|
+
# app/agents/audio/
|
|
20
|
+
# app/agents/embedders/
|
|
21
|
+
# app/workflows/
|
|
22
|
+
#
|
|
23
|
+
# Usage:
|
|
24
|
+
# rails generate ruby_llm_agents:migrate_structure
|
|
25
|
+
# rails generate ruby_llm_agents:migrate_structure --dry-run
|
|
26
|
+
# rails generate ruby_llm_agents:migrate_structure --source-root=ai
|
|
27
|
+
#
|
|
28
|
+
class MigrateStructureGenerator < ::Rails::Generators::Base
|
|
29
|
+
source_root File.expand_path("templates", __dir__)
|
|
30
|
+
|
|
31
|
+
class_option :source_root,
|
|
32
|
+
type: :string,
|
|
33
|
+
default: nil,
|
|
34
|
+
desc: "Source root directory to migrate from (default: auto-detect or 'llm')"
|
|
35
|
+
|
|
36
|
+
class_option :dry_run,
|
|
37
|
+
type: :boolean,
|
|
38
|
+
default: false,
|
|
39
|
+
desc: "Show what would be done without making changes"
|
|
40
|
+
|
|
41
|
+
class_option :skip_namespace_update,
|
|
42
|
+
type: :boolean,
|
|
43
|
+
default: false,
|
|
44
|
+
desc: "Skip updating namespaces in Ruby files"
|
|
45
|
+
|
|
46
|
+
class_option :use_git,
|
|
47
|
+
type: :boolean,
|
|
48
|
+
default: true,
|
|
49
|
+
desc: "Use git mv when in a git repository"
|
|
50
|
+
|
|
51
|
+
# Maps old paths to new paths
|
|
52
|
+
# Format: old_subpath => new_path (relative to app/)
|
|
53
|
+
PATH_MAPPING = {
|
|
54
|
+
# Task agents
|
|
55
|
+
"agents" => "agents",
|
|
56
|
+
|
|
57
|
+
# Image agents (flatten operation types)
|
|
58
|
+
"image/generators" => "agents/images",
|
|
59
|
+
"image/analyzers" => "agents/images",
|
|
60
|
+
"image/editors" => "agents/images",
|
|
61
|
+
"image/upscalers" => "agents/images",
|
|
62
|
+
"image/variators" => "agents/images",
|
|
63
|
+
"image/transformers" => "agents/images",
|
|
64
|
+
"image/background_removers" => "agents/images",
|
|
65
|
+
"image/pipelines" => "agents/images",
|
|
66
|
+
|
|
67
|
+
# Audio agents
|
|
68
|
+
"audio/speakers" => "agents/audio",
|
|
69
|
+
"audio/transcribers" => "agents/audio",
|
|
70
|
+
|
|
71
|
+
# Text operations
|
|
72
|
+
"text/embedders" => "agents/embedders",
|
|
73
|
+
"text/moderators" => "agents/moderators",
|
|
74
|
+
|
|
75
|
+
# Workflows
|
|
76
|
+
"workflows" => "workflows",
|
|
77
|
+
|
|
78
|
+
# Tools
|
|
79
|
+
"tools" => "tools"
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
# Namespace transformations
|
|
83
|
+
# Format: old_namespace_pattern => new_namespace
|
|
84
|
+
NAMESPACE_MAPPING = {
|
|
85
|
+
# Remove root namespace from task agents
|
|
86
|
+
/\A(\w+)::(\w+Agent)\z/ => '\2',
|
|
87
|
+
|
|
88
|
+
# Image namespaces
|
|
89
|
+
/\A(\w+)::Image::(\w+)\z/ => 'Images::\2',
|
|
90
|
+
|
|
91
|
+
# Audio namespaces
|
|
92
|
+
/\A(\w+)::Audio::(\w+)\z/ => 'Audio::\2',
|
|
93
|
+
|
|
94
|
+
# Text namespaces -> Embedders/Moderators
|
|
95
|
+
/\A(\w+)::Text::(\w+Embedder)\z/ => 'Embedders::\2',
|
|
96
|
+
/\A(\w+)::Text::(\w+Moderator)\z/ => 'Moderators::\2',
|
|
97
|
+
|
|
98
|
+
# Workflows (remove root namespace)
|
|
99
|
+
/\A(\w+)::(\w+Workflow)\z/ => '\2'
|
|
100
|
+
}.freeze
|
|
101
|
+
|
|
102
|
+
def check_prerequisites
|
|
103
|
+
@source_root_dir = detect_source_root
|
|
104
|
+
|
|
105
|
+
if @source_root_dir.nil?
|
|
106
|
+
say_status :skip, "No old structure detected. Nothing to migrate.", :yellow
|
|
107
|
+
say ""
|
|
108
|
+
say "If you have an existing structure, specify the source root:"
|
|
109
|
+
say " rails generate ruby_llm_agents:migrate_structure --source-root=llm"
|
|
110
|
+
raise Thor::Error, "Migration aborted: no source found"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
say_status :found, "Old structure at app/#{@source_root_dir}/", :green
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def show_migration_plan
|
|
117
|
+
say ""
|
|
118
|
+
say "=" * 60
|
|
119
|
+
say "Migration Plan", :bold
|
|
120
|
+
say "=" * 60
|
|
121
|
+
say ""
|
|
122
|
+
say "Source: app/#{@source_root_dir}/"
|
|
123
|
+
say "Target: app/agents/ and app/workflows/"
|
|
124
|
+
say ""
|
|
125
|
+
|
|
126
|
+
@files_to_migrate = []
|
|
127
|
+
|
|
128
|
+
PATH_MAPPING.each do |old_subpath, new_path|
|
|
129
|
+
old_full_path = Rails.root.join("app", @source_root_dir, old_subpath)
|
|
130
|
+
next unless File.directory?(old_full_path)
|
|
131
|
+
|
|
132
|
+
files = Dir.glob("#{old_full_path}/**/*.rb")
|
|
133
|
+
next if files.empty?
|
|
134
|
+
|
|
135
|
+
files.each do |file|
|
|
136
|
+
relative = file.sub("#{old_full_path}/", "")
|
|
137
|
+
new_file = Rails.root.join("app", new_path, relative)
|
|
138
|
+
@files_to_migrate << {
|
|
139
|
+
old: file,
|
|
140
|
+
new: new_file.to_s,
|
|
141
|
+
old_display: "app/#{@source_root_dir}/#{old_subpath}/#{relative}",
|
|
142
|
+
new_display: "app/#{new_path}/#{relative}"
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if @files_to_migrate.empty?
|
|
148
|
+
say_status :skip, "No Ruby files found to migrate", :yellow
|
|
149
|
+
return
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
say "Files to migrate (#{@files_to_migrate.size}):"
|
|
153
|
+
@files_to_migrate.each do |f|
|
|
154
|
+
say " #{f[:old_display]}"
|
|
155
|
+
say " -> #{f[:new_display]}", :green
|
|
156
|
+
end
|
|
157
|
+
say ""
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def create_new_directories
|
|
161
|
+
return if @files_to_migrate.nil? || @files_to_migrate.empty?
|
|
162
|
+
|
|
163
|
+
say_status :create, "Creating new directory structure", :green
|
|
164
|
+
|
|
165
|
+
directories = @files_to_migrate.map { |f| File.dirname(f[:new]) }.uniq
|
|
166
|
+
|
|
167
|
+
directories.each do |dir|
|
|
168
|
+
if options[:dry_run]
|
|
169
|
+
say_status :dry_run, "Would create #{dir.sub(Rails.root.to_s + '/', '')}", :yellow
|
|
170
|
+
else
|
|
171
|
+
FileUtils.mkdir_p(dir)
|
|
172
|
+
say_status :mkdir, dir.sub(Rails.root.to_s + "/", ""), :green
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def move_files
|
|
178
|
+
return if @files_to_migrate.nil? || @files_to_migrate.empty?
|
|
179
|
+
|
|
180
|
+
say ""
|
|
181
|
+
say_status :move, "Moving files to new locations", :green
|
|
182
|
+
|
|
183
|
+
@files_to_migrate.each do |f|
|
|
184
|
+
if options[:dry_run]
|
|
185
|
+
say_status :dry_run, "Would move #{f[:old_display]} -> #{f[:new_display]}", :yellow
|
|
186
|
+
else
|
|
187
|
+
move_file(f[:old], f[:new])
|
|
188
|
+
say_status :moved, f[:new_display], :green
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def update_namespaces
|
|
194
|
+
return if options[:skip_namespace_update]
|
|
195
|
+
return if @files_to_migrate.nil? || @files_to_migrate.empty?
|
|
196
|
+
|
|
197
|
+
say ""
|
|
198
|
+
say_status :update, "Updating namespaces in Ruby files", :green
|
|
199
|
+
|
|
200
|
+
@files_to_migrate.each do |f|
|
|
201
|
+
file_path = options[:dry_run] ? f[:old] : f[:new]
|
|
202
|
+
next unless File.exist?(file_path)
|
|
203
|
+
|
|
204
|
+
if options[:dry_run]
|
|
205
|
+
say_status :dry_run, "Would update namespaces in #{f[:new_display]}", :yellow
|
|
206
|
+
else
|
|
207
|
+
update_file_namespaces(f[:new])
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def update_base_classes
|
|
213
|
+
return if options[:skip_namespace_update]
|
|
214
|
+
return if @files_to_migrate.nil? || @files_to_migrate.empty?
|
|
215
|
+
|
|
216
|
+
say ""
|
|
217
|
+
say_status :update, "Updating base class references", :green
|
|
218
|
+
|
|
219
|
+
@files_to_migrate.each do |f|
|
|
220
|
+
file_path = options[:dry_run] ? f[:old] : f[:new]
|
|
221
|
+
next unless File.exist?(file_path)
|
|
222
|
+
|
|
223
|
+
if options[:dry_run]
|
|
224
|
+
say_status :dry_run, "Would update base classes in #{f[:new_display]}", :yellow
|
|
225
|
+
else
|
|
226
|
+
update_file_base_classes(f[:new])
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def cleanup_old_directories
|
|
232
|
+
return if @files_to_migrate.nil? || @files_to_migrate.empty?
|
|
233
|
+
|
|
234
|
+
say ""
|
|
235
|
+
say_status :cleanup, "Cleaning up old directories", :green
|
|
236
|
+
|
|
237
|
+
# Collect all old directories
|
|
238
|
+
old_dirs = PATH_MAPPING.keys.map do |old_subpath|
|
|
239
|
+
Rails.root.join("app", @source_root_dir, old_subpath)
|
|
240
|
+
end.select { |d| File.directory?(d) }
|
|
241
|
+
|
|
242
|
+
# Sort by depth (deepest first) for proper cleanup
|
|
243
|
+
old_dirs.sort_by { |d| -d.to_s.count("/") }.each do |dir|
|
|
244
|
+
cleanup_directory(dir)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Try to remove the root directory if empty
|
|
248
|
+
root_dir = Rails.root.join("app", @source_root_dir)
|
|
249
|
+
if File.directory?(root_dir)
|
|
250
|
+
cleanup_directory(root_dir)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def show_completion_message
|
|
255
|
+
say ""
|
|
256
|
+
say "=" * 60
|
|
257
|
+
say ""
|
|
258
|
+
|
|
259
|
+
if options[:dry_run]
|
|
260
|
+
say "Dry run complete! No changes were made.", :yellow
|
|
261
|
+
say ""
|
|
262
|
+
say "To perform the actual migration, run:"
|
|
263
|
+
say " rails generate ruby_llm_agents:migrate_structure"
|
|
264
|
+
else
|
|
265
|
+
say "Migration complete!", :green
|
|
266
|
+
say ""
|
|
267
|
+
say "Your app now uses the new directory structure:"
|
|
268
|
+
say ""
|
|
269
|
+
say " app/"
|
|
270
|
+
say " ├── agents/"
|
|
271
|
+
say " │ ├── application_agent.rb"
|
|
272
|
+
say " │ ├── your_agent.rb"
|
|
273
|
+
say " │ ├── images/"
|
|
274
|
+
say " │ ├── audio/"
|
|
275
|
+
say " │ ├── embedders/"
|
|
276
|
+
say " │ └── moderators/"
|
|
277
|
+
say " └── workflows/"
|
|
278
|
+
say ""
|
|
279
|
+
say "Next steps:"
|
|
280
|
+
say " 1. Update class references in your code:"
|
|
281
|
+
say " - Llm::Image::ProductGenerator -> Images::ProductGenerator"
|
|
282
|
+
say " - Llm::Audio::MeetingTranscriber -> Audio::MeetingTranscriber"
|
|
283
|
+
say " - Llm::Text::SemanticEmbedder -> Embedders::SemanticEmbedder"
|
|
284
|
+
say " 2. Run your test suite: bundle exec rspec"
|
|
285
|
+
say " 3. Commit the changes: git add -A && git commit -m 'Migrate to new agent structure'"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
say ""
|
|
289
|
+
say "=" * 60
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
private
|
|
293
|
+
|
|
294
|
+
def detect_source_root
|
|
295
|
+
# Check for explicitly provided source root
|
|
296
|
+
if options[:source_root]
|
|
297
|
+
path = Rails.root.join("app", options[:source_root])
|
|
298
|
+
return options[:source_root] if File.directory?(path)
|
|
299
|
+
say_status :warning, "Specified source root 'app/#{options[:source_root]}/' not found", :yellow
|
|
300
|
+
return nil
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Try to get from configuration
|
|
304
|
+
if defined?(RubyLLM::Agents) && RubyLLM::Agents.respond_to?(:configuration)
|
|
305
|
+
config_root = RubyLLM::Agents.configuration.root_directory
|
|
306
|
+
if config_root.present?
|
|
307
|
+
path = Rails.root.join("app", config_root)
|
|
308
|
+
return config_root if File.directory?(path)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Auto-detect common root directories
|
|
313
|
+
%w[llm ai ml agents].each do |candidate|
|
|
314
|
+
path = Rails.root.join("app", candidate)
|
|
315
|
+
if File.directory?(path) && has_old_structure?(path)
|
|
316
|
+
return candidate
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def has_old_structure?(path)
|
|
324
|
+
# Check if this looks like an old ruby_llm-agents structure
|
|
325
|
+
old_indicators = %w[
|
|
326
|
+
agents
|
|
327
|
+
image/generators
|
|
328
|
+
audio/speakers
|
|
329
|
+
text/embedders
|
|
330
|
+
workflows
|
|
331
|
+
]
|
|
332
|
+
|
|
333
|
+
old_indicators.any? do |indicator|
|
|
334
|
+
File.directory?(File.join(path, indicator))
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def in_git_repo?
|
|
339
|
+
@in_git_repo ||= File.directory?(Rails.root.join(".git"))
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def move_file(old_path, new_path)
|
|
343
|
+
if options[:use_git] && in_git_repo?
|
|
344
|
+
# Use git mv to preserve history
|
|
345
|
+
old_relative = old_path.sub(Rails.root.to_s + "/", "")
|
|
346
|
+
new_relative = new_path.sub(Rails.root.to_s + "/", "")
|
|
347
|
+
system("git", "mv", old_relative, new_relative, chdir: Rails.root.to_s)
|
|
348
|
+
else
|
|
349
|
+
FileUtils.mv(old_path, new_path)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def update_file_namespaces(file_path)
|
|
354
|
+
content = File.read(file_path)
|
|
355
|
+
original_content = content.dup
|
|
356
|
+
modified = false
|
|
357
|
+
|
|
358
|
+
# Find and replace module/class declarations with old namespaces
|
|
359
|
+
# Pattern: module OldRoot ... end wrapping
|
|
360
|
+
old_root_pattern = /^module\s+#{Regexp.escape(camelize(@source_root_dir))}\s*$/
|
|
361
|
+
|
|
362
|
+
if content.match?(old_root_pattern)
|
|
363
|
+
# Remove the old root module wrapper
|
|
364
|
+
content = remove_root_module_wrapper(content, camelize(@source_root_dir))
|
|
365
|
+
modified = true
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Update module declarations for nested namespaces
|
|
369
|
+
# Image:: -> Images::
|
|
370
|
+
content.gsub!(/\bmodule\s+Image\b/, "module Images") && (modified = true)
|
|
371
|
+
|
|
372
|
+
# Text:: -> nothing (embedders/moderators are top-level now)
|
|
373
|
+
# This is handled by the module removal above
|
|
374
|
+
|
|
375
|
+
if modified && content != original_content
|
|
376
|
+
File.write(file_path, content)
|
|
377
|
+
say_status :updated, file_path.sub(Rails.root.to_s + "/", ""), :blue
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def update_file_base_classes(file_path)
|
|
382
|
+
content = File.read(file_path)
|
|
383
|
+
original_content = content.dup
|
|
384
|
+
|
|
385
|
+
# Update base class references
|
|
386
|
+
replacements = {
|
|
387
|
+
# Application bases with old namespace
|
|
388
|
+
/class\s+Application(\w+)\s*<\s*#{Regexp.escape(camelize(@source_root_dir))}::/ =>
|
|
389
|
+
'class Application\1 < ',
|
|
390
|
+
|
|
391
|
+
# Specific base classes
|
|
392
|
+
/ApplicationImageGenerator/ => "ApplicationImageGenerator",
|
|
393
|
+
/ApplicationImageAnalyzer/ => "ApplicationImageAnalyzer",
|
|
394
|
+
/ApplicationImageEditor/ => "ApplicationImageEditor",
|
|
395
|
+
/ApplicationTranscriber/ => "ApplicationTranscriber",
|
|
396
|
+
/ApplicationSpeaker/ => "ApplicationSpeaker",
|
|
397
|
+
/ApplicationEmbedder/ => "ApplicationEmbedder"
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
replacements.each do |pattern, replacement|
|
|
401
|
+
content.gsub!(pattern, replacement)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
if content != original_content
|
|
405
|
+
File.write(file_path, content)
|
|
406
|
+
say_status :updated, "base classes in #{file_path.sub(Rails.root.to_s + '/', '')}", :blue
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
def remove_root_module_wrapper(content, root_module)
|
|
411
|
+
lines = content.lines
|
|
412
|
+
result = []
|
|
413
|
+
module_depth = 0
|
|
414
|
+
root_module_line = nil
|
|
415
|
+
|
|
416
|
+
lines.each_with_index do |line, idx|
|
|
417
|
+
if line.match?(/^\s*module\s+#{Regexp.escape(root_module)}\s*$/)
|
|
418
|
+
root_module_line = idx
|
|
419
|
+
module_depth = 1
|
|
420
|
+
next
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
if root_module_line
|
|
424
|
+
# Track module/class/def depth
|
|
425
|
+
if line.match?(/^\s*(module|class)\s+\w/)
|
|
426
|
+
module_depth += 1
|
|
427
|
+
elsif line.match?(/^\s*end\s*$/)
|
|
428
|
+
module_depth -= 1
|
|
429
|
+
if module_depth == 0
|
|
430
|
+
# This is the closing end of the root module, skip it
|
|
431
|
+
root_module_line = nil
|
|
432
|
+
next
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Dedent by 2 spaces
|
|
437
|
+
result << line.sub(/^ /, "")
|
|
438
|
+
else
|
|
439
|
+
result << line
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
result.join
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def cleanup_directory(dir)
|
|
447
|
+
return unless File.directory?(dir)
|
|
448
|
+
|
|
449
|
+
# First clean up any empty subdirectories
|
|
450
|
+
Dir.glob("#{dir}/**/").sort_by { |d| -d.count("/") }.each do |subdir|
|
|
451
|
+
next unless File.directory?(subdir) && Dir.empty?(subdir)
|
|
452
|
+
|
|
453
|
+
if options[:dry_run]
|
|
454
|
+
say_status :dry_run, "Would remove empty #{subdir.sub(Rails.root.to_s + '/', '')}", :yellow
|
|
455
|
+
else
|
|
456
|
+
FileUtils.rmdir(subdir)
|
|
457
|
+
say_status :removed, subdir.sub(Rails.root.to_s + "/", ""), :red
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Then try to remove the directory itself if empty
|
|
462
|
+
return unless File.directory?(dir) && Dir.empty?(dir)
|
|
463
|
+
|
|
464
|
+
if options[:dry_run]
|
|
465
|
+
say_status :dry_run, "Would remove empty #{dir.sub(Rails.root.to_s + '/', '')}", :yellow
|
|
466
|
+
else
|
|
467
|
+
FileUtils.rmdir(dir)
|
|
468
|
+
say_status :removed, dir.sub(Rails.root.to_s + "/", ""), :red
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def camelize(str)
|
|
473
|
+
return "AI" if str.downcase == "ai"
|
|
474
|
+
return "ML" if str.downcase == "ml"
|
|
475
|
+
return "LLM" if str.downcase == "llm"
|
|
476
|
+
|
|
477
|
+
str.split(/[-_]/).map(&:capitalize).join
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
end
|
|
@@ -10,8 +10,11 @@ module RubyLlmAgents
|
|
|
10
10
|
# rails generate ruby_llm_agents:multi_tenancy
|
|
11
11
|
#
|
|
12
12
|
# This will create migrations for:
|
|
13
|
-
# -
|
|
14
|
-
# - Adding
|
|
13
|
+
# - ruby_llm_agents_tenants table for per-tenant configuration
|
|
14
|
+
# - Adding tenant columns to ruby_llm_agents_executions
|
|
15
|
+
#
|
|
16
|
+
# For users upgrading from an older version:
|
|
17
|
+
# - Renames ruby_llm_agents_tenant_budgets to ruby_llm_agents_tenants
|
|
15
18
|
#
|
|
16
19
|
class MultiTenancyGenerator < ::Rails::Generators::Base
|
|
17
20
|
include ::ActiveRecord::Generators::Migration
|
|
@@ -20,21 +23,31 @@ module RubyLlmAgents
|
|
|
20
23
|
|
|
21
24
|
desc "Adds multi-tenancy support to RubyLLM::Agents"
|
|
22
25
|
|
|
23
|
-
def
|
|
24
|
-
if table_exists?(:
|
|
25
|
-
say_status :skip, "
|
|
26
|
+
def create_tenants_migration
|
|
27
|
+
if table_exists?(:ruby_llm_agents_tenants)
|
|
28
|
+
say_status :skip, "ruby_llm_agents_tenants table already exists", :yellow
|
|
26
29
|
return
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
if table_exists?(:ruby_llm_agents_tenant_budgets)
|
|
33
|
+
# Upgrade path: rename existing table
|
|
34
|
+
say_status :upgrade, "Renaming tenant_budgets to tenants", :blue
|
|
35
|
+
migration_template(
|
|
36
|
+
"rename_tenant_budgets_to_tenants_migration.rb.tt",
|
|
37
|
+
File.join(db_migrate_path, "rename_tenant_budgets_to_tenants.rb")
|
|
38
|
+
)
|
|
39
|
+
else
|
|
40
|
+
# Fresh install: create new table
|
|
41
|
+
migration_template(
|
|
42
|
+
"create_tenants_migration.rb.tt",
|
|
43
|
+
File.join(db_migrate_path, "create_ruby_llm_agents_tenants.rb")
|
|
44
|
+
)
|
|
45
|
+
end
|
|
33
46
|
end
|
|
34
47
|
|
|
35
48
|
def create_add_tenant_to_executions_migration
|
|
36
49
|
if column_exists?(:ruby_llm_agents_executions, :tenant_id)
|
|
37
|
-
say_status :skip, "tenant_id column already exists", :yellow
|
|
50
|
+
say_status :skip, "tenant_id column already exists on executions", :yellow
|
|
38
51
|
return
|
|
39
52
|
end
|
|
40
53
|
|
|
@@ -50,23 +63,30 @@ module RubyLlmAgents
|
|
|
50
63
|
say ""
|
|
51
64
|
say "Next steps:"
|
|
52
65
|
say " 1. Run: rails db:migrate"
|
|
53
|
-
say " 2.
|
|
66
|
+
say " 2. Add llm_tenant to your tenant model:"
|
|
54
67
|
say ""
|
|
55
|
-
say "
|
|
56
|
-
say "
|
|
57
|
-
say "
|
|
68
|
+
say " class Organization < ApplicationRecord"
|
|
69
|
+
say " include RubyLLM::Agents::LLMTenant"
|
|
70
|
+
say ""
|
|
71
|
+
say " llm_tenant id: :id, # Method for tenant_id"
|
|
72
|
+
say " budget: true, # Auto-create budget on creation"
|
|
73
|
+
say " limits: { # Optional default limits"
|
|
74
|
+
say " daily_cost: 100,"
|
|
75
|
+
say " monthly_cost: 1000"
|
|
76
|
+
say " },"
|
|
77
|
+
say " enforcement: :hard # :none, :soft, or :hard"
|
|
58
78
|
say " end"
|
|
59
79
|
say ""
|
|
60
|
-
say " 3.
|
|
80
|
+
say " 3. Pass tenant to agents:"
|
|
81
|
+
say ""
|
|
82
|
+
say " MyAgent.call(prompt, tenant: current_organization)"
|
|
61
83
|
say ""
|
|
62
|
-
say " 4.
|
|
84
|
+
say " 4. Query usage:"
|
|
63
85
|
say ""
|
|
64
|
-
say " RubyLLM::Agents::
|
|
65
|
-
say "
|
|
66
|
-
say "
|
|
67
|
-
say "
|
|
68
|
-
say " enforcement: 'hard'"
|
|
69
|
-
say " )"
|
|
86
|
+
say " tenant = RubyLLM::Agents::Tenant.for(organization)"
|
|
87
|
+
say " tenant.cost_today # => 12.34"
|
|
88
|
+
say " tenant.tokens_this_month # => 50000"
|
|
89
|
+
say " tenant.usage_summary # => { cost: ..., tokens: ..., ... }"
|
|
70
90
|
say ""
|
|
71
91
|
end
|
|
72
92
|
|
|
@@ -121,7 +121,7 @@ module RubyLlmAgents
|
|
|
121
121
|
namespace = config.namespace_for(mapping[:category])
|
|
122
122
|
|
|
123
123
|
Dir.glob("#{directory_path}/**/*.rb").each do |file|
|
|
124
|
-
update_file_namespace(file, namespace)
|
|
124
|
+
update_file_namespace(file, namespace) if namespace
|
|
125
125
|
end
|
|
126
126
|
end
|
|
127
127
|
end
|
|
@@ -190,7 +190,7 @@ module RubyLlmAgents
|
|
|
190
190
|
private
|
|
191
191
|
|
|
192
192
|
def root_directory
|
|
193
|
-
@root_directory ||= options[:root] || RubyLLM::Agents.configuration.root_directory
|
|
193
|
+
@root_directory ||= options[:root] || RubyLLM::Agents.configuration.root_directory || "agents"
|
|
194
194
|
end
|
|
195
195
|
|
|
196
196
|
def root_namespace
|
|
@@ -9,10 +9,9 @@ module RubyLlmAgents
|
|
|
9
9
|
# rails generate ruby_llm_agents:speaker Narrator
|
|
10
10
|
# rails generate ruby_llm_agents:speaker Narrator --provider elevenlabs
|
|
11
11
|
# rails generate ruby_llm_agents:speaker Narrator --voice alloy --speed 1.25
|
|
12
|
-
# rails generate ruby_llm_agents:speaker Narrator --root=ai
|
|
13
12
|
#
|
|
14
13
|
# This will create:
|
|
15
|
-
# - app/
|
|
14
|
+
# - app/agents/audio/narrator_speaker.rb
|
|
16
15
|
#
|
|
17
16
|
class SpeakerGenerator < ::Rails::Generators::NamedBase
|
|
18
17
|
source_root File.expand_path("templates", __dir__)
|
|
@@ -29,48 +28,36 @@ module RubyLlmAgents
|
|
|
29
28
|
desc: "Output format (mp3, wav, ogg, flac)"
|
|
30
29
|
class_option :cache, type: :string, default: nil,
|
|
31
30
|
desc: "Cache TTL (e.g., '7.days')"
|
|
32
|
-
class_option :root,
|
|
33
|
-
type: :string,
|
|
34
|
-
default: nil,
|
|
35
|
-
desc: "Root directory name (default: uses config or 'llm')"
|
|
36
|
-
class_option :namespace,
|
|
37
|
-
type: :string,
|
|
38
|
-
default: nil,
|
|
39
|
-
desc: "Root namespace (default: camelized root or config)"
|
|
40
31
|
|
|
41
32
|
def ensure_base_class_and_skill_file
|
|
42
|
-
|
|
43
|
-
@audio_namespace = "#{root_namespace}::Audio"
|
|
44
|
-
speakers_dir = "app/#{root_directory}/audio/speakers"
|
|
33
|
+
audio_dir = "app/agents/audio"
|
|
45
34
|
|
|
46
35
|
# Create directory if needed
|
|
47
|
-
empty_directory
|
|
36
|
+
empty_directory audio_dir
|
|
48
37
|
|
|
49
38
|
# Create base class if it doesn't exist
|
|
50
|
-
base_class_path = "#{
|
|
39
|
+
base_class_path = "#{audio_dir}/application_speaker.rb"
|
|
51
40
|
unless File.exist?(File.join(destination_root, base_class_path))
|
|
52
41
|
template "application_speaker.rb.tt", base_class_path
|
|
53
42
|
end
|
|
54
43
|
|
|
55
44
|
# Create skill file if it doesn't exist
|
|
56
|
-
skill_file_path = "#{
|
|
45
|
+
skill_file_path = "#{audio_dir}/SPEAKERS.md"
|
|
57
46
|
unless File.exist?(File.join(destination_root, skill_file_path))
|
|
58
47
|
template "skills/SPEAKERS.md.tt", skill_file_path
|
|
59
48
|
end
|
|
60
49
|
end
|
|
61
50
|
|
|
62
51
|
def create_speaker_file
|
|
63
|
-
# Support nested paths: "article/narrator" -> "app/
|
|
64
|
-
@root_namespace = root_namespace
|
|
65
|
-
@audio_namespace = "#{root_namespace}::Audio"
|
|
52
|
+
# Support nested paths: "article/narrator" -> "app/agents/audio/article/narrator_speaker.rb"
|
|
66
53
|
speaker_path = name.underscore
|
|
67
|
-
template "speaker.rb.tt", "app
|
|
54
|
+
template "speaker.rb.tt", "app/agents/audio/#{speaker_path}_speaker.rb"
|
|
68
55
|
end
|
|
69
56
|
|
|
70
57
|
def show_usage
|
|
71
58
|
# Build full class name from path
|
|
72
59
|
speaker_class_name = name.split("/").map(&:camelize).join("::")
|
|
73
|
-
full_class_name = "
|
|
60
|
+
full_class_name = "Audio::#{speaker_class_name}Speaker"
|
|
74
61
|
say ""
|
|
75
62
|
say "Speaker #{full_class_name} created!", :green
|
|
76
63
|
say ""
|
|
@@ -91,24 +78,6 @@ module RubyLlmAgents
|
|
|
91
78
|
|
|
92
79
|
private
|
|
93
80
|
|
|
94
|
-
def root_directory
|
|
95
|
-
@root_directory ||= options[:root] || RubyLLM::Agents.configuration.root_directory
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def root_namespace
|
|
99
|
-
@root_namespace ||= options[:namespace] || camelize(root_directory)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def camelize(str)
|
|
103
|
-
# Handle special cases for common abbreviations
|
|
104
|
-
return "AI" if str.downcase == "ai"
|
|
105
|
-
return "ML" if str.downcase == "ml"
|
|
106
|
-
return "LLM" if str.downcase == "llm"
|
|
107
|
-
|
|
108
|
-
# Standard camelization
|
|
109
|
-
str.split(/[-_]/).map(&:capitalize).join
|
|
110
|
-
end
|
|
111
|
-
|
|
112
81
|
def default_model
|
|
113
82
|
case options[:provider].to_s
|
|
114
83
|
when "elevenlabs"
|