ruby_llm-agents 0.4.0 → 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 (33) 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/execution.rb +83 -0
  26. data/lib/ruby_llm/agents/base.rb +1 -0
  27. data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
  28. data/lib/ruby_llm/agents/configuration.rb +38 -1
  29. data/lib/ruby_llm/agents/engine.rb +1 -0
  30. data/lib/ruby_llm/agents/instrumentation.rb +71 -3
  31. data/lib/ruby_llm/agents/resolved_config.rb +348 -0
  32. data/lib/ruby_llm/agents/version.rb +1 -1
  33. metadata +19 -3
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Database-backed API configuration for LLM providers
6
+ #
7
+ # Stores API keys (encrypted at rest) and configuration options that can be
8
+ # managed via the dashboard UI. Supports both global settings and per-tenant
9
+ # overrides.
10
+ #
11
+ # Resolution priority: per-tenant DB > global DB > config file (RubyLLM.configure)
12
+ #
13
+ # @!attribute [rw] scope_type
14
+ # @return [String] Either 'global' or 'tenant'
15
+ # @!attribute [rw] scope_id
16
+ # @return [String, nil] Tenant ID when scope_type='tenant'
17
+ #
18
+ # @example Setting global API keys
19
+ # config = ApiConfiguration.global
20
+ # config.update!(
21
+ # openai_api_key: "sk-...",
22
+ # anthropic_api_key: "sk-ant-..."
23
+ # )
24
+ #
25
+ # @example Setting tenant-specific configuration
26
+ # tenant_config = ApiConfiguration.for_tenant!("acme_corp")
27
+ # tenant_config.update!(
28
+ # anthropic_api_key: "sk-ant-tenant-specific...",
29
+ # default_model: "claude-sonnet-4-20250514"
30
+ # )
31
+ #
32
+ # @example Resolving configuration for a tenant
33
+ # resolved = ApiConfiguration.resolve(tenant_id: "acme_corp")
34
+ # resolved.apply_to_ruby_llm! # Apply to RubyLLM.configuration
35
+ #
36
+ # @see ResolvedConfig
37
+ # @api public
38
+ class ApiConfiguration < ::ActiveRecord::Base
39
+ self.table_name = "ruby_llm_agents_api_configurations"
40
+
41
+ # Valid scope types
42
+ SCOPE_TYPES = %w[global tenant].freeze
43
+
44
+ # All API key attributes that should be encrypted
45
+ API_KEY_ATTRIBUTES = %i[
46
+ openai_api_key
47
+ anthropic_api_key
48
+ gemini_api_key
49
+ deepseek_api_key
50
+ mistral_api_key
51
+ perplexity_api_key
52
+ openrouter_api_key
53
+ gpustack_api_key
54
+ xai_api_key
55
+ ollama_api_key
56
+ bedrock_api_key
57
+ bedrock_secret_key
58
+ bedrock_session_token
59
+ vertexai_credentials
60
+ ].freeze
61
+
62
+ # All endpoint attributes
63
+ ENDPOINT_ATTRIBUTES = %i[
64
+ openai_api_base
65
+ gemini_api_base
66
+ ollama_api_base
67
+ gpustack_api_base
68
+ xai_api_base
69
+ ].freeze
70
+
71
+ # All default model attributes
72
+ MODEL_ATTRIBUTES = %i[
73
+ default_model
74
+ default_embedding_model
75
+ default_image_model
76
+ default_moderation_model
77
+ ].freeze
78
+
79
+ # Connection settings attributes
80
+ CONNECTION_ATTRIBUTES = %i[
81
+ request_timeout
82
+ max_retries
83
+ retry_interval
84
+ retry_backoff_factor
85
+ retry_interval_randomness
86
+ http_proxy
87
+ ].freeze
88
+
89
+ # All configurable attributes (excluding API keys)
90
+ NON_KEY_ATTRIBUTES = (
91
+ ENDPOINT_ATTRIBUTES +
92
+ MODEL_ATTRIBUTES +
93
+ CONNECTION_ATTRIBUTES +
94
+ %i[
95
+ openai_organization_id
96
+ openai_project_id
97
+ bedrock_region
98
+ vertexai_project_id
99
+ vertexai_location
100
+ ]
101
+ ).freeze
102
+
103
+ # Encrypt all API keys using Rails encrypted attributes
104
+ # Requires Rails encryption to be configured (rails credentials:edit)
105
+ API_KEY_ATTRIBUTES.each do |key_attr|
106
+ encrypts key_attr, deterministic: false
107
+ end
108
+
109
+ # Validations
110
+ validates :scope_type, presence: true, inclusion: { in: SCOPE_TYPES }
111
+ validates :scope_id, uniqueness: { scope: :scope_type }, allow_nil: true
112
+ validate :validate_scope_consistency
113
+
114
+ # Scopes
115
+ scope :global_config, -> { where(scope_type: "global", scope_id: nil) }
116
+ scope :for_scope, ->(type, id) { where(scope_type: type, scope_id: id) }
117
+ scope :tenant_configs, -> { where(scope_type: "tenant") }
118
+
119
+ # Provider configuration mappings for display
120
+ PROVIDERS = {
121
+ openai: {
122
+ name: "OpenAI",
123
+ key_attr: :openai_api_key,
124
+ base_attr: :openai_api_base,
125
+ extra_attrs: [:openai_organization_id, :openai_project_id],
126
+ capabilities: ["Chat", "Embeddings", "Images", "Moderation"]
127
+ },
128
+ anthropic: {
129
+ name: "Anthropic",
130
+ key_attr: :anthropic_api_key,
131
+ capabilities: ["Chat"]
132
+ },
133
+ gemini: {
134
+ name: "Google Gemini",
135
+ key_attr: :gemini_api_key,
136
+ base_attr: :gemini_api_base,
137
+ capabilities: ["Chat", "Embeddings", "Images"]
138
+ },
139
+ deepseek: {
140
+ name: "DeepSeek",
141
+ key_attr: :deepseek_api_key,
142
+ capabilities: ["Chat"]
143
+ },
144
+ mistral: {
145
+ name: "Mistral",
146
+ key_attr: :mistral_api_key,
147
+ capabilities: ["Chat", "Embeddings"]
148
+ },
149
+ perplexity: {
150
+ name: "Perplexity",
151
+ key_attr: :perplexity_api_key,
152
+ capabilities: ["Chat"]
153
+ },
154
+ openrouter: {
155
+ name: "OpenRouter",
156
+ key_attr: :openrouter_api_key,
157
+ capabilities: ["Chat"]
158
+ },
159
+ gpustack: {
160
+ name: "GPUStack",
161
+ key_attr: :gpustack_api_key,
162
+ base_attr: :gpustack_api_base,
163
+ capabilities: ["Chat"]
164
+ },
165
+ xai: {
166
+ name: "xAI",
167
+ key_attr: :xai_api_key,
168
+ base_attr: :xai_api_base,
169
+ capabilities: ["Chat"]
170
+ },
171
+ ollama: {
172
+ name: "Ollama",
173
+ key_attr: :ollama_api_key,
174
+ base_attr: :ollama_api_base,
175
+ capabilities: ["Chat", "Embeddings"]
176
+ },
177
+ bedrock: {
178
+ name: "AWS Bedrock",
179
+ key_attr: :bedrock_api_key,
180
+ extra_attrs: [:bedrock_secret_key, :bedrock_session_token, :bedrock_region],
181
+ capabilities: ["Chat", "Embeddings"]
182
+ },
183
+ vertexai: {
184
+ name: "Google Vertex AI",
185
+ key_attr: :vertexai_credentials,
186
+ extra_attrs: [:vertexai_project_id, :vertexai_location],
187
+ capabilities: ["Chat", "Embeddings"]
188
+ }
189
+ }.freeze
190
+
191
+ class << self
192
+ # Finds or creates the global configuration
193
+ #
194
+ # @return [ApiConfiguration] The global configuration record
195
+ def global
196
+ global_config.first_or_create!
197
+ end
198
+
199
+ # Finds a tenant-specific configuration
200
+ #
201
+ # @param tenant_id [String] The tenant identifier
202
+ # @return [ApiConfiguration, nil] The tenant configuration or nil
203
+ def for_tenant(tenant_id)
204
+ return nil if tenant_id.blank?
205
+
206
+ for_scope("tenant", tenant_id).first
207
+ end
208
+
209
+ # Finds or creates a tenant-specific configuration
210
+ #
211
+ # @param tenant_id [String] The tenant identifier
212
+ # @return [ApiConfiguration] The tenant configuration record
213
+ def for_tenant!(tenant_id)
214
+ raise ArgumentError, "tenant_id cannot be blank" if tenant_id.blank?
215
+
216
+ for_scope("tenant", tenant_id).first_or_create!(
217
+ scope_type: "tenant",
218
+ scope_id: tenant_id
219
+ )
220
+ end
221
+
222
+ # Resolves the effective configuration for a given tenant
223
+ #
224
+ # Creates a ResolvedConfig that combines tenant config > global DB > RubyLLM config
225
+ #
226
+ # @param tenant_id [String, nil] Optional tenant identifier
227
+ # @return [ResolvedConfig] The resolved configuration
228
+ def resolve(tenant_id: nil)
229
+ tenant_config = tenant_id.present? ? for_tenant(tenant_id) : nil
230
+ global = global_config.first
231
+
232
+ RubyLLM::Agents::ResolvedConfig.new(
233
+ tenant_config: tenant_config,
234
+ global_config: global,
235
+ ruby_llm_config: ruby_llm_current_config
236
+ )
237
+ end
238
+
239
+ # Attempts to get the current RubyLLM configuration object
240
+ # Gets the current RubyLLM configuration object
241
+ #
242
+ # @return [Object, nil] The RubyLLM config object or nil
243
+ def ruby_llm_current_config
244
+ return nil unless defined?(::RubyLLM)
245
+ return nil unless RubyLLM.respond_to?(:config)
246
+
247
+ RubyLLM.config
248
+ rescue StandardError
249
+ nil
250
+ end
251
+
252
+ # Checks if the table exists (for graceful degradation)
253
+ #
254
+ # @return [Boolean]
255
+ def table_exists?
256
+ connection.table_exists?(table_name)
257
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
258
+ false
259
+ end
260
+ end
261
+
262
+ # Checks if a specific attribute has a value set
263
+ #
264
+ # @param attr_name [Symbol, String] The attribute name
265
+ # @return [Boolean]
266
+ def has_value?(attr_name)
267
+ value = send(attr_name)
268
+ value.present?
269
+ rescue NoMethodError
270
+ false
271
+ end
272
+
273
+ # Returns a masked version of an API key for display
274
+ #
275
+ # @param attr_name [Symbol, String] The API key attribute name
276
+ # @return [String, nil] Masked key like "sk-ab****wxyz" or nil
277
+ def masked_key(attr_name)
278
+ value = send(attr_name)
279
+ return nil if value.blank?
280
+
281
+ mask_string(value)
282
+ end
283
+
284
+ # Returns the source of this configuration
285
+ #
286
+ # @return [String] "global" or "tenant:ID"
287
+ def source_label
288
+ scope_type == "global" ? "Global" : "Tenant: #{scope_id}"
289
+ end
290
+
291
+ # Converts this configuration to a hash suitable for RubyLLM
292
+ #
293
+ # @return [Hash] Configuration hash with non-nil values
294
+ def to_ruby_llm_config
295
+ {}.tap do |config|
296
+ # API keys
297
+ config[:openai_api_key] = openai_api_key if openai_api_key.present?
298
+ config[:anthropic_api_key] = anthropic_api_key if anthropic_api_key.present?
299
+ config[:gemini_api_key] = gemini_api_key if gemini_api_key.present?
300
+ config[:deepseek_api_key] = deepseek_api_key if deepseek_api_key.present?
301
+ config[:mistral_api_key] = mistral_api_key if mistral_api_key.present?
302
+ config[:perplexity_api_key] = perplexity_api_key if perplexity_api_key.present?
303
+ config[:openrouter_api_key] = openrouter_api_key if openrouter_api_key.present?
304
+ config[:gpustack_api_key] = gpustack_api_key if gpustack_api_key.present?
305
+ config[:xai_api_key] = xai_api_key if xai_api_key.present?
306
+ config[:ollama_api_key] = ollama_api_key if ollama_api_key.present?
307
+
308
+ # Bedrock
309
+ config[:bedrock_api_key] = bedrock_api_key if bedrock_api_key.present?
310
+ config[:bedrock_secret_key] = bedrock_secret_key if bedrock_secret_key.present?
311
+ config[:bedrock_session_token] = bedrock_session_token if bedrock_session_token.present?
312
+ config[:bedrock_region] = bedrock_region if bedrock_region.present?
313
+
314
+ # Vertex AI
315
+ config[:vertexai_credentials] = vertexai_credentials if vertexai_credentials.present?
316
+ config[:vertexai_project_id] = vertexai_project_id if vertexai_project_id.present?
317
+ config[:vertexai_location] = vertexai_location if vertexai_location.present?
318
+
319
+ # Endpoints
320
+ config[:openai_api_base] = openai_api_base if openai_api_base.present?
321
+ config[:gemini_api_base] = gemini_api_base if gemini_api_base.present?
322
+ config[:ollama_api_base] = ollama_api_base if ollama_api_base.present?
323
+ config[:gpustack_api_base] = gpustack_api_base if gpustack_api_base.present?
324
+ config[:xai_api_base] = xai_api_base if xai_api_base.present?
325
+
326
+ # OpenAI options
327
+ config[:openai_organization_id] = openai_organization_id if openai_organization_id.present?
328
+ config[:openai_project_id] = openai_project_id if openai_project_id.present?
329
+
330
+ # Default models
331
+ config[:default_model] = default_model if default_model.present?
332
+ config[:default_embedding_model] = default_embedding_model if default_embedding_model.present?
333
+ config[:default_image_model] = default_image_model if default_image_model.present?
334
+ config[:default_moderation_model] = default_moderation_model if default_moderation_model.present?
335
+
336
+ # Connection settings
337
+ config[:request_timeout] = request_timeout if request_timeout.present?
338
+ config[:max_retries] = max_retries if max_retries.present?
339
+ config[:retry_interval] = retry_interval if retry_interval.present?
340
+ config[:retry_backoff_factor] = retry_backoff_factor if retry_backoff_factor.present?
341
+ config[:retry_interval_randomness] = retry_interval_randomness if retry_interval_randomness.present?
342
+ config[:http_proxy] = http_proxy if http_proxy.present?
343
+ end
344
+ end
345
+
346
+ # Returns provider status information for display
347
+ #
348
+ # @return [Array<Hash>] Array of provider status hashes
349
+ def provider_statuses
350
+ PROVIDERS.map do |key, info|
351
+ key_value = send(info[:key_attr])
352
+ {
353
+ key: key,
354
+ name: info[:name],
355
+ configured: key_value.present?,
356
+ masked_key: key_value.present? ? mask_string(key_value) : nil,
357
+ capabilities: info[:capabilities],
358
+ has_base_url: info[:base_attr].present? && send(info[:base_attr]).present?
359
+ }
360
+ end
361
+ end
362
+
363
+ private
364
+
365
+ # Validates scope consistency
366
+ def validate_scope_consistency
367
+ if scope_type == "global" && scope_id.present?
368
+ errors.add(:scope_id, "must be nil for global scope")
369
+ elsif scope_type == "tenant" && scope_id.blank?
370
+ errors.add(:scope_id, "must be present for tenant scope")
371
+ end
372
+ end
373
+
374
+ # Masks a string for display (shows first 2 and last 4 chars)
375
+ #
376
+ # @param value [String] The string to mask
377
+ # @return [String] Masked string
378
+ def mask_string(value)
379
+ return nil if value.blank?
380
+ return "****" if value.length <= 8
381
+
382
+ "#{value[0..1]}****#{value[-4..]}"
383
+ end
384
+ end
385
+ end
386
+ end
@@ -6,34 +6,45 @@ module RubyLLM
6
6
  #
7
7
  # Stores per-tenant budget limits that override the global configuration.
8
8
  # Supports runtime updates without application restarts.
9
+ # Supports both cost-based (USD) and token-based limits.
9
10
  #
10
11
  # @!attribute [rw] tenant_id
11
12
  # @return [String] Unique identifier for the tenant
13
+ # @!attribute [rw] name
14
+ # @return [String, nil] Human-readable name for the tenant
12
15
  # @!attribute [rw] daily_limit
13
16
  # @return [BigDecimal, nil] Daily budget limit in USD
14
17
  # @!attribute [rw] monthly_limit
15
18
  # @return [BigDecimal, nil] Monthly budget limit in USD
19
+ # @!attribute [rw] daily_token_limit
20
+ # @return [Integer, nil] Daily token limit (across all models)
21
+ # @!attribute [rw] monthly_token_limit
22
+ # @return [Integer, nil] Monthly token limit (across all models)
16
23
  # @!attribute [rw] per_agent_daily
17
- # @return [Hash] Per-agent daily limits: { "AgentName" => limit }
24
+ # @return [Hash] Per-agent daily cost limits: { "AgentName" => limit }
18
25
  # @!attribute [rw] per_agent_monthly
19
- # @return [Hash] Per-agent monthly limits: { "AgentName" => limit }
26
+ # @return [Hash] Per-agent monthly cost limits: { "AgentName" => limit }
20
27
  # @!attribute [rw] enforcement
21
28
  # @return [String] Enforcement mode: "none", "soft", or "hard"
22
29
  # @!attribute [rw] inherit_global_defaults
23
30
  # @return [Boolean] Whether to fall back to global config for unset limits
24
31
  #
25
- # @example Creating a tenant budget
32
+ # @example Creating a tenant budget with cost and token limits
26
33
  # TenantBudget.create!(
27
34
  # tenant_id: "acme_corp",
28
- # daily_limit: 50.0,
29
- # monthly_limit: 500.0,
35
+ # name: "Acme Corporation",
36
+ # daily_limit: 50.0, # USD
37
+ # monthly_limit: 500.0, # USD
38
+ # daily_token_limit: 1_000_000,
39
+ # monthly_token_limit: 10_000_000,
30
40
  # per_agent_daily: { "ContentAgent" => 10.0 },
31
41
  # enforcement: "hard"
32
42
  # )
33
43
  #
34
44
  # @example Fetching budget for a tenant
35
45
  # budget = TenantBudget.for_tenant("acme_corp")
36
- # budget.effective_daily_limit # => 50.0
46
+ # budget.effective_daily_limit # => 50.0 (cost)
47
+ # budget.effective_daily_token_limit # => 1_000_000 (tokens)
37
48
  #
38
49
  # @see RubyLLM::Agents::BudgetTracker
39
50
  # @api public
@@ -48,6 +59,8 @@ module RubyLLM
48
59
  validates :enforcement, inclusion: { in: ENFORCEMENT_MODES }, allow_nil: true
49
60
  validates :daily_limit, :monthly_limit,
50
61
  numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
62
+ validates :daily_token_limit, :monthly_token_limit,
63
+ numericality: { greater_than_or_equal_to: 0, only_integer: true }, allow_nil: true
51
64
 
52
65
  # Finds a budget for the given tenant
53
66
  #
@@ -59,6 +72,24 @@ module RubyLLM
59
72
  find_by(tenant_id: tenant_id)
60
73
  end
61
74
 
75
+ # Finds or creates a budget for the given tenant
76
+ #
77
+ # @param tenant_id [String] The tenant identifier
78
+ # @param name [String, nil] Optional human-readable name
79
+ # @return [TenantBudget] The budget record
80
+ def self.for_tenant!(tenant_id, name: nil)
81
+ find_or_create_by!(tenant_id: tenant_id) do |budget|
82
+ budget.name = name
83
+ end
84
+ end
85
+
86
+ # Returns the display name (name or tenant_id fallback)
87
+ #
88
+ # @return [String] The name to display
89
+ def display_name
90
+ name.presence || tenant_id
91
+ end
92
+
62
93
  # Returns the effective daily limit, considering inheritance
63
94
  #
64
95
  # @return [Float, nil] The daily limit or nil if not set
@@ -103,6 +134,26 @@ module RubyLLM
103
134
  global_config&.dig(:per_agent_monthly, agent_type)
104
135
  end
105
136
 
137
+ # Returns the effective daily token limit, considering inheritance
138
+ #
139
+ # @return [Integer, nil] The daily token limit or nil if not set
140
+ def effective_daily_token_limit
141
+ return daily_token_limit if daily_token_limit.present?
142
+ return nil unless inherit_global_defaults
143
+
144
+ global_config&.dig(:global_daily_tokens)
145
+ end
146
+
147
+ # Returns the effective monthly token limit, considering inheritance
148
+ #
149
+ # @return [Integer, nil] The monthly token limit or nil if not set
150
+ def effective_monthly_token_limit
151
+ return monthly_token_limit if monthly_token_limit.present?
152
+ return nil unless inherit_global_defaults
153
+
154
+ global_config&.dig(:global_monthly_tokens)
155
+ end
156
+
106
157
  # Returns the effective enforcement mode
107
158
  #
108
159
  # @return [Symbol] :none, :soft, or :hard
@@ -127,10 +178,14 @@ module RubyLLM
127
178
  {
128
179
  enabled: budgets_enabled?,
129
180
  enforcement: effective_enforcement,
181
+ # Cost limits
130
182
  global_daily: effective_daily_limit,
131
183
  global_monthly: effective_monthly_limit,
132
184
  per_agent_daily: merged_per_agent_daily,
133
- per_agent_monthly: merged_per_agent_monthly
185
+ per_agent_monthly: merged_per_agent_monthly,
186
+ # Token limits (global only)
187
+ global_daily_tokens: effective_daily_token_limit,
188
+ global_monthly_tokens: effective_monthly_token_limit
134
189
  }
135
190
  end
136
191
 
@@ -230,7 +230,9 @@
230
230
  { path: ruby_llm_agents.root_path, label: "Dashboard", icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />' },
231
231
  { path: ruby_llm_agents.agents_path, label: "Agents", icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />' },
232
232
  { path: ruby_llm_agents.executions_path, label: "Executions", icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />' },
233
- { path: ruby_llm_agents.settings_path, label: "Settings", icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />' }
233
+ { path: ruby_llm_agents.tenants_path, label: "Tenants", icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />' },
234
+ { path: ruby_llm_agents.system_config_path, label: "System Config", icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />' },
235
+ { path: ruby_llm_agents.api_configuration_path, label: "API Keys", icon: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />' }
234
236
  ]
235
237
  %>
236
238
  <nav class="hidden md:flex items-center space-x-1">
@@ -0,0 +1,34 @@
1
+ <% # Partial for API key input field %>
2
+ <% # Local variables: f (form builder), config (ApiConfiguration), resolved (ResolvedConfig, optional), attr (symbol), label (string), placeholder (string, optional), hint (string, optional) %>
3
+ <% resolved = local_assigns[:resolved] %>
4
+ <% config_value = resolved&.ruby_llm_value_for(attr) %>
5
+ <% db_value = config.send(attr) %>
6
+
7
+ <div>
8
+ <%= f.label attr, label, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
9
+ <div class="mt-1 relative rounded-md shadow-sm">
10
+ <%= f.password_field attr,
11
+ class: "block w-full px-3 py-2 rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm pr-10",
12
+ placeholder: config.has_value?(attr) ? "[Key set - leave blank to keep]" : (local_assigns[:placeholder] || "Enter API key..."),
13
+ autocomplete: "off",
14
+ value: db_value,
15
+ data: { key_field: attr, config_value: config_value, db_value: db_value } %>
16
+ <% if config.has_value?(attr) %>
17
+ <div class="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
18
+ <svg class="h-5 w-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
19
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
20
+ </svg>
21
+ </div>
22
+ <% end %>
23
+ </div>
24
+ <% if local_assigns[:hint] %>
25
+ <p class="mt-1 text-xs text-gray-500 dark:text-gray-400"><%= hint %></p>
26
+ <% end %>
27
+ <% if config_value.present? %>
28
+ <p class="mt-1 text-xs text-gray-500 dark:text-gray-400 config-value-hint"
29
+ data-full-value="<%= config_value %>"
30
+ data-masked-value="<%= resolved.mask_string(config_value) %>">
31
+ From config: <code class="bg-gray-100 dark:bg-gray-700 px-1 rounded config-value-display"><%= resolved.mask_string(config_value) %></code>
32
+ </p>
33
+ <% end %>
34
+ </div>