ruby_llm-agents 1.1.0 → 1.2.1

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.
@@ -0,0 +1,310 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Tenant
6
+ # Tracks LLM usage for a tenant including costs, tokens, and execution counts.
7
+ #
8
+ # Provides methods for querying usage data with various time periods:
9
+ # - :today, :yesterday, :this_week, :last_week
10
+ # - :this_month, :last_month
11
+ # - Custom date ranges
12
+ #
13
+ # @example Querying costs
14
+ # tenant.cost # Total cost all time
15
+ # tenant.cost_today # Today's cost
16
+ # tenant.cost_this_month # This month's cost
17
+ # tenant.cost(period: 1.week.ago..Time.current)
18
+ #
19
+ # @example Usage summary
20
+ # tenant.usage_summary
21
+ # # => { tenant_id: "acme", cost: 123.45, tokens: 50000, executions: 100 }
22
+ #
23
+ # @example Usage breakdown
24
+ # tenant.usage_by_agent
25
+ # # => { "ChatAgent" => { cost: 50.0, tokens: 20000, count: 40 } }
26
+ #
27
+ # @see Execution
28
+ # @api public
29
+ module Trackable
30
+ extend ActiveSupport::Concern
31
+
32
+ included do
33
+ # Association to executions via tenant_id
34
+ has_many :executions,
35
+ class_name: "RubyLLM::Agents::Execution",
36
+ primary_key: :tenant_id,
37
+ foreign_key: :tenant_id,
38
+ inverse_of: false
39
+ end
40
+
41
+ # Cost queries
42
+
43
+ # Returns total cost for the given period
44
+ #
45
+ # @param period [Symbol, Range, nil] Time period (:today, :this_month, etc.) or date range
46
+ # @return [Float] Total cost in USD
47
+ def cost(period: nil)
48
+ scope = executions
49
+ scope = apply_period_scope(scope, period) if period
50
+ scope.sum(:total_cost) || 0
51
+ end
52
+
53
+ # Returns today's cost
54
+ #
55
+ # @return [Float]
56
+ def cost_today
57
+ cost(period: :today)
58
+ end
59
+
60
+ # Returns yesterday's cost
61
+ #
62
+ # @return [Float]
63
+ def cost_yesterday
64
+ cost(period: :yesterday)
65
+ end
66
+
67
+ # Returns this week's cost
68
+ #
69
+ # @return [Float]
70
+ def cost_this_week
71
+ cost(period: :this_week)
72
+ end
73
+
74
+ # Returns last week's cost
75
+ #
76
+ # @return [Float]
77
+ def cost_last_week
78
+ cost(period: :last_week)
79
+ end
80
+
81
+ # Returns this month's cost
82
+ #
83
+ # @return [Float]
84
+ def cost_this_month
85
+ cost(period: :this_month)
86
+ end
87
+
88
+ # Returns last month's cost
89
+ #
90
+ # @return [Float]
91
+ def cost_last_month
92
+ cost(period: :last_month)
93
+ end
94
+
95
+ # Token queries
96
+
97
+ # Returns total tokens for the given period
98
+ #
99
+ # @param period [Symbol, Range, nil] Time period or date range
100
+ # @return [Integer] Total tokens
101
+ def tokens(period: nil)
102
+ scope = executions
103
+ scope = apply_period_scope(scope, period) if period
104
+ scope.sum(:total_tokens) || 0
105
+ end
106
+
107
+ # Returns today's token usage
108
+ #
109
+ # @return [Integer]
110
+ def tokens_today
111
+ tokens(period: :today)
112
+ end
113
+
114
+ # Returns yesterday's token usage
115
+ #
116
+ # @return [Integer]
117
+ def tokens_yesterday
118
+ tokens(period: :yesterday)
119
+ end
120
+
121
+ # Returns this week's token usage
122
+ #
123
+ # @return [Integer]
124
+ def tokens_this_week
125
+ tokens(period: :this_week)
126
+ end
127
+
128
+ # Returns this month's token usage
129
+ #
130
+ # @return [Integer]
131
+ def tokens_this_month
132
+ tokens(period: :this_month)
133
+ end
134
+
135
+ # Returns last month's token usage
136
+ #
137
+ # @return [Integer]
138
+ def tokens_last_month
139
+ tokens(period: :last_month)
140
+ end
141
+
142
+ # Execution count queries
143
+
144
+ # Returns execution count for the given period
145
+ #
146
+ # @param period [Symbol, Range, nil] Time period or date range
147
+ # @return [Integer] Execution count
148
+ def execution_count(period: nil)
149
+ scope = executions
150
+ scope = apply_period_scope(scope, period) if period
151
+ scope.count
152
+ end
153
+
154
+ # Returns today's execution count
155
+ #
156
+ # @return [Integer]
157
+ def executions_today
158
+ execution_count(period: :today)
159
+ end
160
+
161
+ # Returns yesterday's execution count
162
+ #
163
+ # @return [Integer]
164
+ def executions_yesterday
165
+ execution_count(period: :yesterday)
166
+ end
167
+
168
+ # Returns this week's execution count
169
+ #
170
+ # @return [Integer]
171
+ def executions_this_week
172
+ execution_count(period: :this_week)
173
+ end
174
+
175
+ # Returns this month's execution count
176
+ #
177
+ # @return [Integer]
178
+ def executions_this_month
179
+ execution_count(period: :this_month)
180
+ end
181
+
182
+ # Returns last month's execution count
183
+ #
184
+ # @return [Integer]
185
+ def executions_last_month
186
+ execution_count(period: :last_month)
187
+ end
188
+
189
+ # Usage summaries
190
+
191
+ # Returns a complete usage summary for the tenant
192
+ #
193
+ # @param period [Symbol, Range] Time period (default: :this_month)
194
+ # @return [Hash] Usage summary
195
+ def usage_summary(period: :this_month)
196
+ {
197
+ tenant_id: tenant_id,
198
+ name: display_name,
199
+ period: period,
200
+ cost: cost(period: period),
201
+ tokens: tokens(period: period),
202
+ executions: execution_count(period: period)
203
+ }
204
+ end
205
+
206
+ # Returns usage broken down by agent type
207
+ #
208
+ # @param period [Symbol, Range] Time period (default: :this_month)
209
+ # @return [Hash] Usage by agent { "AgentName" => { cost:, tokens:, count: } }
210
+ def usage_by_agent(period: :this_month)
211
+ scope = executions
212
+ scope = apply_period_scope(scope, period) if period
213
+
214
+ scope.group(:agent_type).pluck(
215
+ :agent_type,
216
+ Arel.sql("SUM(total_cost)"),
217
+ Arel.sql("SUM(total_tokens)"),
218
+ Arel.sql("COUNT(*)")
219
+ ).to_h do |agent_type, total_cost, total_tokens, count|
220
+ [agent_type, { cost: total_cost || 0, tokens: total_tokens || 0, count: count }]
221
+ end
222
+ end
223
+
224
+ # Returns usage broken down by model
225
+ #
226
+ # @param period [Symbol, Range] Time period (default: :this_month)
227
+ # @return [Hash] Usage by model { "model-id" => { cost:, tokens:, count: } }
228
+ def usage_by_model(period: :this_month)
229
+ scope = executions
230
+ scope = apply_period_scope(scope, period) if period
231
+
232
+ scope.group(:model_id).pluck(
233
+ :model_id,
234
+ Arel.sql("SUM(total_cost)"),
235
+ Arel.sql("SUM(total_tokens)"),
236
+ Arel.sql("COUNT(*)")
237
+ ).to_h do |model_id, total_cost, total_tokens, count|
238
+ [model_id, { cost: total_cost || 0, tokens: total_tokens || 0, count: count }]
239
+ end
240
+ end
241
+
242
+ # Returns usage broken down by day for a given period
243
+ #
244
+ # @param period [Symbol, Range] Time period (default: :this_month)
245
+ # @return [Hash] Daily usage { Date => { cost:, tokens:, count: } }
246
+ def usage_by_day(period: :this_month)
247
+ scope = executions
248
+ scope = apply_period_scope(scope, period) if period
249
+
250
+ scope.group("DATE(created_at)").pluck(
251
+ Arel.sql("DATE(created_at)"),
252
+ Arel.sql("SUM(total_cost)"),
253
+ Arel.sql("SUM(total_tokens)"),
254
+ Arel.sql("COUNT(*)")
255
+ ).to_h do |date, total_cost, total_tokens, count|
256
+ [date.to_date, { cost: total_cost || 0, tokens: total_tokens || 0, count: count }]
257
+ end
258
+ end
259
+
260
+ # Returns the most recent executions for this tenant
261
+ #
262
+ # @param limit [Integer] Number of executions to return (default: 10)
263
+ # @return [Array<Execution>]
264
+ def recent_executions(limit: 10)
265
+ executions.order(created_at: :desc).limit(limit)
266
+ end
267
+
268
+ # Returns failed executions for this tenant
269
+ #
270
+ # @param period [Symbol, Range, nil] Time period
271
+ # @param limit [Integer, nil] Optional limit
272
+ # @return [ActiveRecord::Relation]
273
+ def failed_executions(period: nil, limit: nil)
274
+ scope = executions.where(status: "error")
275
+ scope = apply_period_scope(scope, period) if period
276
+ scope = scope.limit(limit) if limit
277
+ scope.order(created_at: :desc)
278
+ end
279
+
280
+ private
281
+
282
+ # Applies time period scope to a query
283
+ #
284
+ # @param scope [ActiveRecord::Relation]
285
+ # @param period [Symbol, Range]
286
+ # @return [ActiveRecord::Relation]
287
+ def apply_period_scope(scope, period)
288
+ case period
289
+ when :today
290
+ scope.where(created_at: Time.current.all_day)
291
+ when :yesterday
292
+ scope.where(created_at: 1.day.ago.all_day)
293
+ when :this_week
294
+ scope.where(created_at: Time.current.all_week)
295
+ when :last_week
296
+ scope.where(created_at: 1.week.ago.all_week)
297
+ when :this_month
298
+ scope.where(created_at: Time.current.all_month)
299
+ when :last_month
300
+ scope.where(created_at: 1.month.ago.all_month)
301
+ when Range
302
+ scope.where(created_at: period)
303
+ else
304
+ scope
305
+ end
306
+ end
307
+ end
308
+ end
309
+ end
310
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Central model for tenant management in multi-tenant LLM applications.
6
+ #
7
+ # Encapsulates all tenant-related functionality:
8
+ # - Budget limits and enforcement (via Budgetable concern)
9
+ # - Usage tracking: cost, tokens, executions (via Trackable concern)
10
+ # - API configuration per tenant (via Configurable concern)
11
+ #
12
+ # @example Creating a tenant
13
+ # Tenant.create!(
14
+ # tenant_id: "acme_corp",
15
+ # name: "Acme Corporation",
16
+ # daily_limit: 100.0,
17
+ # enforcement: "hard"
18
+ # )
19
+ #
20
+ # @example Linking to user's model
21
+ # Tenant.create!(
22
+ # tenant_id: organization.id.to_s,
23
+ # tenant_record: organization,
24
+ # daily_limit: 100.0
25
+ # )
26
+ #
27
+ # @example Finding a tenant
28
+ # tenant = Tenant.for(organization)
29
+ # tenant = Tenant.for("acme_corp")
30
+ #
31
+ # @see Tenant::Budgetable
32
+ # @see Tenant::Trackable
33
+ # @see Tenant::Configurable
34
+ # @see LLMTenant
35
+ # @api public
36
+ class Tenant < ::ActiveRecord::Base
37
+ self.table_name = "ruby_llm_agents_tenants"
38
+
39
+ # Include concerns for organized functionality
40
+ include Tenant::Budgetable
41
+ include Tenant::Trackable
42
+ include Tenant::Configurable
43
+
44
+ # Polymorphic association to user's tenant model (optional)
45
+ # Allows linking to Organization, Account, or any ActiveRecord model
46
+ belongs_to :tenant_record, polymorphic: true, optional: true
47
+
48
+ # Validations
49
+ validates :tenant_id, presence: true, uniqueness: true
50
+
51
+ # Scopes
52
+ scope :active, -> { where(active: true) }
53
+ scope :inactive, -> { where(active: false) }
54
+ scope :linked, -> { where.not(tenant_record_type: nil) }
55
+ scope :unlinked, -> { where(tenant_record_type: nil) }
56
+
57
+ # Find tenant for given record or ID
58
+ #
59
+ # Supports multiple lookup strategies:
60
+ # 1. ActiveRecord model - looks up by polymorphic association first, then tenant_id
61
+ # 2. Object with llm_tenant_id - looks up by tenant_id
62
+ # 3. String - looks up by tenant_id
63
+ #
64
+ # @param tenant [String, ActiveRecord::Base, Object] Tenant ID, model, or object with llm_tenant_id
65
+ # @return [Tenant, nil] The tenant record or nil if not found
66
+ #
67
+ # @example Find by model
68
+ # Tenant.for(organization)
69
+ #
70
+ # @example Find by string ID
71
+ # Tenant.for("acme_corp")
72
+ #
73
+ def self.for(tenant)
74
+ return nil if tenant.blank?
75
+
76
+ if tenant.is_a?(::ActiveRecord::Base)
77
+ # ActiveRecord model - try polymorphic first, then tenant_id
78
+ find_by(tenant_record: tenant) ||
79
+ find_by(tenant_id: tenant.try(:llm_tenant_id) || tenant.id.to_s)
80
+ elsif tenant.respond_to?(:llm_tenant_id)
81
+ # Object with llm_tenant_id method
82
+ find_by(tenant_id: tenant.llm_tenant_id)
83
+ else
84
+ # String tenant_id
85
+ find_by(tenant_id: tenant.to_s)
86
+ end
87
+ end
88
+
89
+ # Find or create tenant
90
+ #
91
+ # @param tenant_id [String] Unique tenant identifier
92
+ # @param attributes [Hash] Additional attributes for creation
93
+ # @return [Tenant]
94
+ #
95
+ # @example
96
+ # Tenant.for!("acme_corp", name: "Acme Corporation")
97
+ #
98
+ def self.for!(tenant_id, **attributes)
99
+ find_or_create_by!(tenant_id: tenant_id.to_s) do |tenant|
100
+ tenant.assign_attributes(attributes)
101
+ end
102
+ end
103
+
104
+ # Backward compatible class method aliases
105
+ class << self
106
+ alias_method :for_tenant, :for
107
+ alias_method :for_tenant!, :for!
108
+ end
109
+
110
+ # Display name (name or tenant_id fallback)
111
+ #
112
+ # @return [String]
113
+ def display_name
114
+ name.presence || tenant_id
115
+ end
116
+
117
+ # Check if tenant is linked to a user model
118
+ #
119
+ # @return [Boolean]
120
+ def linked?
121
+ tenant_record.present?
122
+ end
123
+
124
+ # Check if tenant is active
125
+ #
126
+ # @return [Boolean]
127
+ def active?
128
+ active != false
129
+ end
130
+
131
+ # Deactivate the tenant
132
+ #
133
+ # @return [Boolean]
134
+ def deactivate!
135
+ update!(active: false)
136
+ end
137
+
138
+ # Activate the tenant
139
+ #
140
+ # @return [Boolean]
141
+ def activate!
142
+ update!(active: true)
143
+ end
144
+ end
145
+ end
146
+ end