ruby_llm-agents 0.3.6 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -13
  3. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
  4. data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
  6. data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
  7. data/app/models/ruby_llm/agents/tenant_budget.rb +62 -7
  8. data/app/views/layouts/ruby_llm/agents/application.html.erb +3 -1
  9. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
  10. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
  11. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
  12. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
  13. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
  14. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
  15. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +82 -0
  17. data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
  18. data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
  19. data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
  22. data/config/routes.rb +12 -1
  23. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
  24. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
  25. data/lib/ruby_llm/agents/base/dsl.rb +65 -13
  26. data/lib/ruby_llm/agents/base/execution.rb +113 -6
  27. data/lib/ruby_llm/agents/base/reliability_dsl.rb +82 -0
  28. data/lib/ruby_llm/agents/base.rb +28 -0
  29. data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
  30. data/lib/ruby_llm/agents/configuration.rb +38 -1
  31. data/lib/ruby_llm/agents/deprecations.rb +77 -0
  32. data/lib/ruby_llm/agents/engine.rb +1 -0
  33. data/lib/ruby_llm/agents/instrumentation.rb +71 -3
  34. data/lib/ruby_llm/agents/reliability/breaker_manager.rb +80 -0
  35. data/lib/ruby_llm/agents/reliability/execution_constraints.rb +69 -0
  36. data/lib/ruby_llm/agents/reliability/executor.rb +124 -0
  37. data/lib/ruby_llm/agents/reliability/fallback_routing.rb +72 -0
  38. data/lib/ruby_llm/agents/reliability/retry_strategy.rb +76 -0
  39. data/lib/ruby_llm/agents/resolved_config.rb +348 -0
  40. data/lib/ruby_llm/agents/result.rb +72 -2
  41. data/lib/ruby_llm/agents/version.rb +1 -1
  42. data/lib/ruby_llm/agents.rb +6 -0
  43. metadata +26 -3
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to create the api_configurations table
4
+ #
5
+ # This table stores API key configurations that can be managed via the dashboard.
6
+ # Supports both global settings and per-tenant overrides.
7
+ #
8
+ # Resolution priority: per-tenant DB > global DB > config file (RubyLLM.configure)
9
+ #
10
+ # Features:
11
+ # - Encrypted storage for all API keys (using Rails encrypted attributes)
12
+ # - Support for all major LLM providers
13
+ # - Custom endpoint configuration
14
+ # - Connection settings
15
+ # - Default model configuration
16
+ #
17
+ # Run with: rails db:migrate
18
+ class CreateRubyLLMAgentsApiConfigurations < ActiveRecord::Migration<%= migration_version %>
19
+ def change
20
+ create_table :ruby_llm_agents_api_configurations do |t|
21
+ # Scope type: 'global' or 'tenant'
22
+ t.string :scope_type, null: false, default: 'global'
23
+ # Tenant ID when scope_type='tenant'
24
+ t.string :scope_id
25
+
26
+ # === Encrypted API Keys ===
27
+ # Rails encrypts stores encrypted data in the same-named column
28
+ # Primary providers
29
+ t.text :openai_api_key
30
+ t.text :anthropic_api_key
31
+ t.text :gemini_api_key
32
+
33
+ # Additional providers
34
+ t.text :deepseek_api_key
35
+ t.text :mistral_api_key
36
+ t.text :perplexity_api_key
37
+ t.text :openrouter_api_key
38
+ t.text :gpustack_api_key
39
+ t.text :xai_api_key
40
+ t.text :ollama_api_key
41
+
42
+ # AWS Bedrock
43
+ t.text :bedrock_api_key
44
+ t.text :bedrock_secret_key
45
+ t.text :bedrock_session_token
46
+ t.string :bedrock_region
47
+
48
+ # Google Vertex AI
49
+ t.text :vertexai_credentials
50
+ t.string :vertexai_project_id
51
+ t.string :vertexai_location
52
+
53
+ # === Custom Endpoints ===
54
+ t.string :openai_api_base
55
+ t.string :gemini_api_base
56
+ t.string :ollama_api_base
57
+ t.string :gpustack_api_base
58
+ t.string :xai_api_base
59
+
60
+ # === OpenAI Options ===
61
+ t.string :openai_organization_id
62
+ t.string :openai_project_id
63
+
64
+ # === Default Models ===
65
+ t.string :default_model
66
+ t.string :default_embedding_model
67
+ t.string :default_image_model
68
+ t.string :default_moderation_model
69
+
70
+ # === Connection Settings ===
71
+ t.integer :request_timeout
72
+ t.integer :max_retries
73
+ t.decimal :retry_interval, precision: 5, scale: 2
74
+ t.decimal :retry_backoff_factor, precision: 5, scale: 2
75
+ t.decimal :retry_interval_randomness, precision: 5, scale: 2
76
+ t.string :http_proxy
77
+
78
+ # Whether to inherit from global config for unset values
79
+ t.boolean :inherit_global_defaults, default: true
80
+
81
+ t.timestamps
82
+ end
83
+
84
+ # Ensure unique scope_type + scope_id combinations
85
+ add_index :ruby_llm_agents_api_configurations, [:scope_type, :scope_id], unique: true, name: 'idx_api_configs_scope'
86
+
87
+ # Index for faster tenant lookups
88
+ add_index :ruby_llm_agents_api_configurations, :scope_id, name: 'idx_api_configs_scope_id'
89
+ end
90
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "reliability_dsl"
4
+
3
5
  module RubyLLM
4
6
  module Agents
5
7
  class Base
@@ -74,6 +76,31 @@ module RubyLLM
74
76
 
75
77
  # @!group Reliability DSL
76
78
 
79
+ # Configures reliability features using a block syntax
80
+ #
81
+ # Groups all reliability configuration in a single block for clarity.
82
+ # Individual methods (retries, fallback_models, etc.) remain available
83
+ # for backward compatibility.
84
+ #
85
+ # @yield Block containing reliability configuration
86
+ # @return [void]
87
+ # @example
88
+ # reliability do
89
+ # retries max: 3, backoff: :exponential
90
+ # fallback_models "gpt-4o-mini"
91
+ # total_timeout 30
92
+ # circuit_breaker errors: 5
93
+ # end
94
+ def reliability(&block)
95
+ builder = ReliabilityDSL.new
96
+ builder.instance_eval(&block)
97
+
98
+ @retries_config = builder.retries_config if builder.retries_config
99
+ @fallback_models = builder.fallback_models_list if builder.fallback_models_list.any?
100
+ @total_timeout = builder.total_timeout_value if builder.total_timeout_value
101
+ @circuit_breaker_config = builder.circuit_breaker_config if builder.circuit_breaker_config
102
+ end
103
+
77
104
  # Configures retry behavior for this agent
78
105
  #
79
106
  # @param max [Integer] Maximum number of retry attempts (default: 0)
@@ -162,13 +189,18 @@ module RubyLLM
162
189
  # @param name [Symbol] The parameter name
163
190
  # @param required [Boolean] Whether the parameter is required
164
191
  # @param default [Object, nil] Default value if not provided
192
+ # @param type [Class, nil] Optional type for validation (e.g., String, Integer, Array)
165
193
  # @return [void]
166
- # @example
194
+ # @example Without type (accepts anything)
167
195
  # param :query, required: true
168
- # param :limit, default: 10
169
- def param(name, required: false, default: nil)
196
+ # param :data, default: {}
197
+ # @example With type validation
198
+ # param :limit, default: 10, type: Integer
199
+ # param :name, type: String
200
+ # param :tags, type: Array
201
+ def param(name, required: false, default: nil, type: nil)
170
202
  @params ||= {}
171
- @params[name] = { required: required, default: default }
203
+ @params[name] = { required: required, default: default, type: type }
172
204
  define_method(name) do
173
205
  @options[name] || @options[name.to_s] || self.class.params.dig(name, :default)
174
206
  end
@@ -186,17 +218,37 @@ module RubyLLM
186
218
 
187
219
  # @!group Caching DSL
188
220
 
189
- # Enables caching for this agent with optional TTL
221
+ # Enables caching for this agent with explicit TTL
222
+ #
223
+ # This is the preferred method for enabling caching.
190
224
  #
191
225
  # @param ttl [ActiveSupport::Duration] Time-to-live for cached responses
192
226
  # @return [void]
193
227
  # @example
194
- # cache 1.hour
195
- def cache(ttl = CACHE_TTL)
228
+ # cache_for 1.hour
229
+ # cache_for 30.minutes
230
+ def cache_for(ttl)
196
231
  @cache_enabled = true
197
232
  @cache_ttl = ttl
198
233
  end
199
234
 
235
+ # Enables caching for this agent with optional TTL
236
+ #
237
+ # @deprecated Use {#cache_for} instead for clarity.
238
+ # This method will be removed in version 1.0.
239
+ # @param ttl [ActiveSupport::Duration] Time-to-live for cached responses
240
+ # @return [void]
241
+ # @example
242
+ # cache 1.hour # deprecated
243
+ # cache_for 1.hour # preferred
244
+ def cache(ttl = CACHE_TTL)
245
+ RubyLLM::Agents::Deprecations.warn(
246
+ "cache(ttl) is deprecated. Use cache_for(ttl) instead for clarity.",
247
+ caller
248
+ )
249
+ cache_for(ttl)
250
+ end
251
+
200
252
  # Returns whether caching is enabled for this agent
201
253
  #
202
254
  # @return [Boolean] true if caching is enabled
@@ -243,13 +295,13 @@ module RubyLLM
243
295
  #
244
296
  # @param tool_classes [Array<Class>] Tool classes to make available
245
297
  # @return [Array<Class>] The current tools
298
+ # @example With array (preferred)
299
+ # tools [WeatherTool, SearchTool, CalculatorTool]
246
300
  # @example Single tool
247
- # tools WeatherTool
248
- # @example Multiple tools
249
- # tools WeatherTool, SearchTool, CalculatorTool
250
- def tools(*tool_classes)
251
- if tool_classes.any?
252
- @tools = tool_classes.flatten
301
+ # tools [WeatherTool]
302
+ def tools(tool_classes = nil)
303
+ if tool_classes
304
+ @tools = Array(tool_classes)
253
305
  end
254
306
  @tools || inherited_or_default(:tools, RubyLLM::Agents.configuration.default_tools)
255
307
  end
@@ -17,6 +17,9 @@ module RubyLLM
17
17
  # @yieldparam chunk [RubyLLM::Chunk] A streaming chunk with content
18
18
  # @return [Object] The processed LLM response
19
19
  def call(&block)
20
+ # Resolve tenant configuration before execution
21
+ resolve_tenant_context!
22
+
20
23
  return dry_run_response if @options[:dry_run]
21
24
  return uncached_call(&block) if @options[:skip_cache] || !self.class.cache_enabled?
22
25
 
@@ -36,6 +39,52 @@ module RubyLLM
36
39
  end
37
40
  end
38
41
 
42
+ # Resolves tenant context from the :tenant option
43
+ #
44
+ # The tenant option can be:
45
+ # - String: Just the tenant_id (uses resolver or DB for config)
46
+ # - Hash: Full config { id:, name:, daily_limit:, daily_token_limit:, ... }
47
+ #
48
+ # @return [void]
49
+ def resolve_tenant_context!
50
+ # Idempotency guard - only resolve once
51
+ return if defined?(@tenant_context_resolved) && @tenant_context_resolved
52
+
53
+ tenant_option = @options[:tenant]
54
+ return unless tenant_option
55
+
56
+ if tenant_option.is_a?(Hash)
57
+ # Full config passed - extract id and store config
58
+ @tenant_id = tenant_option[:id]&.to_s
59
+ @tenant_config = tenant_option.except(:id)
60
+ else
61
+ # Just tenant_id passed
62
+ @tenant_id = tenant_option.to_s
63
+ @tenant_config = nil
64
+ end
65
+
66
+ @tenant_context_resolved = true
67
+ end
68
+
69
+ # Returns the resolved tenant ID
70
+ #
71
+ # @return [String, nil] The tenant identifier
72
+ def resolved_tenant_id
73
+ return @tenant_id if defined?(@tenant_id) && @tenant_id.present?
74
+
75
+ config = RubyLLM::Agents.configuration
76
+ return nil unless config.multi_tenancy_enabled?
77
+
78
+ config.current_tenant_id
79
+ end
80
+
81
+ # Returns the runtime tenant config (if passed via :tenant option)
82
+ #
83
+ # @return [Hash, nil] Runtime tenant configuration
84
+ def runtime_tenant_config
85
+ @tenant_config if defined?(@tenant_config)
86
+ end
87
+
39
88
  # Executes the agent without caching
40
89
  #
41
90
  # Routes to reliability-enabled execution if configured, otherwise
@@ -62,7 +111,7 @@ module RubyLLM
62
111
  reset_accumulated_tool_calls!
63
112
 
64
113
  Timeout.timeout(self.class.timeout) do
65
- if self.class.streaming && block_given?
114
+ if streaming_enabled? && block_given?
66
115
  execute_with_streaming(current_client, &block)
67
116
  else
68
117
  response = current_client.ask(user_prompt, **ask_options)
@@ -177,6 +226,16 @@ module RubyLLM
177
226
  config[:circuit_breaker].present?
178
227
  end
179
228
 
229
+ # Returns whether streaming is enabled for this execution
230
+ #
231
+ # Checks both class-level DSL setting and instance-level override
232
+ # (set by the stream class method).
233
+ #
234
+ # @return [Boolean] true if streaming is enabled
235
+ def streaming_enabled?
236
+ @force_streaming || self.class.streaming
237
+ end
238
+
180
239
  # Returns options to pass to the ask method
181
240
  #
182
241
  # Currently supports :with for attachments (images, PDFs, etc.)
@@ -188,20 +247,37 @@ module RubyLLM
188
247
  opts
189
248
  end
190
249
 
191
- # Validates that all required parameters are present
250
+ # Validates that all required parameters are present and types match
192
251
  #
193
- # @raise [ArgumentError] If required parameters are missing
252
+ # @raise [ArgumentError] If required parameters are missing or types don't match
194
253
  # @return [void]
195
254
  def validate_required_params!
196
- required = self.class.params.select { |_, v| v[:required] }.keys
197
- missing = required.reject { |p| @options.key?(p) || @options.key?(p.to_s) }
198
- raise ArgumentError, "#{self.class} missing required params: #{missing.join(', ')}" if missing.any?
255
+ self.class.params.each do |name, config|
256
+ value = @options[name] || @options[name.to_s]
257
+ has_value = @options.key?(name) || @options.key?(name.to_s)
258
+
259
+ # Check required
260
+ if config[:required] && !has_value
261
+ raise ArgumentError, "#{self.class} missing required param: #{name}"
262
+ end
263
+
264
+ # Check type if specified and value is present (not nil)
265
+ if config[:type] && has_value && !value.nil?
266
+ unless value.is_a?(config[:type])
267
+ raise ArgumentError,
268
+ "#{self.class} expected #{config[:type]} for :#{name}, got #{value.class}"
269
+ end
270
+ end
271
+ end
199
272
  end
200
273
 
201
274
  # Builds and configures the RubyLLM client
202
275
  #
203
276
  # @return [RubyLLM::Chat] Configured chat client
204
277
  def build_client
278
+ # Apply database-backed API configuration if available
279
+ apply_api_configuration!
280
+
205
281
  client = RubyLLM.chat
206
282
  .with_model(model)
207
283
  .with_temperature(temperature)
@@ -212,11 +288,42 @@ module RubyLLM
212
288
  client
213
289
  end
214
290
 
291
+ # Applies database-backed API configuration to RubyLLM
292
+ #
293
+ # Resolution priority: per-tenant DB > global DB > RubyLLM.configure
294
+ # Only applies if the api_configurations table exists.
295
+ #
296
+ # @return [void]
297
+ def apply_api_configuration!
298
+ return unless api_configuration_available?
299
+
300
+ resolved_config = ApiConfiguration.resolve(tenant_id: resolved_tenant_id)
301
+ resolved_config.apply_to_ruby_llm!
302
+ rescue StandardError => e
303
+ Rails.logger.warn("[RubyLLM::Agents] Failed to apply API config: #{e.message}")
304
+ end
305
+
306
+ # Checks if API configuration table is available
307
+ #
308
+ # @return [Boolean] true if table exists and is accessible
309
+ def api_configuration_available?
310
+ return @api_config_available if defined?(@api_config_available)
311
+
312
+ @api_config_available = begin
313
+ ApiConfiguration.table_exists?
314
+ rescue StandardError
315
+ false
316
+ end
317
+ end
318
+
215
319
  # Builds a client with a specific model
216
320
  #
217
321
  # @param model_id [String] The model identifier
218
322
  # @return [RubyLLM::Chat] Configured chat client
219
323
  def build_client_with_model(model_id)
324
+ # Apply database-backed API configuration if available
325
+ apply_api_configuration!
326
+
220
327
  client = RubyLLM.chat
221
328
  .with_model(model_id)
222
329
  .with_temperature(temperature)
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # DSL builder for reliability configuration
7
+ #
8
+ # Provides a block-based configuration syntax for grouping
9
+ # all reliability settings together.
10
+ #
11
+ # @example Basic usage
12
+ # class MyAgent < ApplicationAgent
13
+ # reliability do
14
+ # retries max: 3, backoff: :exponential
15
+ # fallback_models "gpt-4o-mini"
16
+ # total_timeout 30
17
+ # circuit_breaker errors: 5, within: 60
18
+ # end
19
+ # end
20
+ #
21
+ # @api public
22
+ class ReliabilityDSL
23
+ attr_reader :retries_config, :fallback_models_list, :total_timeout_value, :circuit_breaker_config
24
+
25
+ def initialize
26
+ @retries_config = nil
27
+ @fallback_models_list = []
28
+ @total_timeout_value = nil
29
+ @circuit_breaker_config = nil
30
+ end
31
+
32
+ # Configures retry behavior
33
+ #
34
+ # @param max [Integer] Maximum retry attempts
35
+ # @param backoff [Symbol] :constant or :exponential
36
+ # @param base [Float] Base delay in seconds
37
+ # @param max_delay [Float] Maximum delay between retries
38
+ # @param on [Array<Class>] Additional error classes to retry on
39
+ # @return [void]
40
+ def retries(max: 0, backoff: :exponential, base: 0.4, max_delay: 3.0, on: [])
41
+ @retries_config = {
42
+ max: max,
43
+ backoff: backoff,
44
+ base: base,
45
+ max_delay: max_delay,
46
+ on: on
47
+ }
48
+ end
49
+
50
+ # Sets fallback models
51
+ #
52
+ # @param models [Array<String>] Model identifiers
53
+ # @return [void]
54
+ def fallback_models(*models)
55
+ @fallback_models_list = models.flatten
56
+ end
57
+
58
+ # Sets total timeout across all retry/fallback attempts
59
+ #
60
+ # @param seconds [Integer] Total timeout in seconds
61
+ # @return [void]
62
+ def total_timeout(seconds)
63
+ @total_timeout_value = seconds
64
+ end
65
+
66
+ # Configures circuit breaker
67
+ #
68
+ # @param errors [Integer] Failure threshold
69
+ # @param within [Integer] Rolling window in seconds
70
+ # @param cooldown [Integer] Cooldown period in seconds
71
+ # @return [void]
72
+ def circuit_breaker(errors: 10, within: 60, cooldown: 300)
73
+ @circuit_breaker_config = {
74
+ errors: errors,
75
+ within: within,
76
+ cooldown: cooldown
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -83,6 +83,33 @@ module RubyLLM
83
83
  def call(*args, **kwargs, &block)
84
84
  new(*args, **kwargs).call(&block)
85
85
  end
86
+
87
+ # Streams agent execution, yielding chunks as they arrive
88
+ #
89
+ # A more explicit alternative to passing a block to call.
90
+ # Forces streaming mode for this invocation regardless of class setting.
91
+ #
92
+ # @param kwargs [Hash] Agent parameters
93
+ # @yield [chunk] Yields each chunk as it arrives
94
+ # @yieldparam chunk [RubyLLM::Chunk] Streaming chunk with content
95
+ # @return [Result] The final result after streaming completes
96
+ # @raise [ArgumentError] If no block is provided
97
+ #
98
+ # @example Basic streaming
99
+ # MyAgent.stream(query: "test") do |chunk|
100
+ # print chunk.content
101
+ # end
102
+ #
103
+ # @example With result metadata
104
+ # result = MyAgent.stream(query: "test") { |c| print c.content }
105
+ # puts "\nTokens: #{result.total_tokens}"
106
+ def stream(**kwargs, &block)
107
+ raise ArgumentError, "Block required for streaming" unless block_given?
108
+
109
+ instance = new(**kwargs)
110
+ instance.instance_variable_set(:@force_streaming, true)
111
+ instance.call(&block)
112
+ end
86
113
  end
87
114
 
88
115
  # @!attribute [r] model
@@ -109,6 +136,7 @@ module RubyLLM
109
136
  @options = options
110
137
  @accumulated_tool_calls = []
111
138
  validate_required_params!
139
+ resolve_tenant_context! # Resolve tenant before building client for API key resolution
112
140
  @client = build_client
113
141
  end
114
142