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.
- checksums.yaml +4 -4
- data/README.md +46 -13
- data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
- data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
- data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
- data/app/models/ruby_llm/agents/tenant_budget.rb +62 -7
- data/app/views/layouts/ruby_llm/agents/application.html.erb +3 -1
- data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
- data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
- data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
- data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
- data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
- data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
- data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
- data/app/views/ruby_llm/agents/executions/show.html.erb +82 -0
- data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
- data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
- data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
- data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
- data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
- data/config/routes.rb +12 -1
- data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
- data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
- data/lib/ruby_llm/agents/base/execution.rb +83 -0
- data/lib/ruby_llm/agents/base.rb +1 -0
- data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
- data/lib/ruby_llm/agents/configuration.rb +38 -1
- data/lib/ruby_llm/agents/engine.rb +1 -0
- data/lib/ruby_llm/agents/instrumentation.rb +71 -3
- data/lib/ruby_llm/agents/resolved_config.rb +348 -0
- data/lib/ruby_llm/agents/version.rb +1 -1
- 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
|
-
#
|
|
29
|
-
#
|
|
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
|
|
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.
|
|
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>
|