Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit 6d3faf1

Browse files
committed
FEATURE: llm quotas
This introduces a new feature for per group quotas
1 parent 11d0f60 commit 6d3faf1

File tree

7 files changed

+470
-0
lines changed

7 files changed

+470
-0
lines changed

app/models/llm_quota.rb

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
class LlmQuota < ActiveRecord::Base
4+
self.table_name = "llm_quotas"
5+
6+
belongs_to :group
7+
belongs_to :llm_model
8+
has_many :llm_quota_usages
9+
10+
validates :group_id, presence: true
11+
validates :llm_model_id, presence: true
12+
validates :duration_seconds, presence: true, numericality: { greater_than: 0 }
13+
validates :max_tokens, numericality: { greater_than: 0, allow_nil: true }
14+
validates :max_usages, numericality: { greater_than: 0, allow_nil: true }
15+
16+
validate :at_least_one_limit
17+
18+
def self.within_quota?(llm, user)
19+
end
20+
21+
def self.log_usage(llm, user, input_tokens, output_tokens)
22+
end
23+
24+
def available_tokens
25+
max_tokens
26+
end
27+
28+
def available_usages
29+
max_usages
30+
end
31+
32+
private
33+
34+
def at_least_one_limit
35+
if max_tokens.nil? && max_usages.nil?
36+
errors.add(:base, I18n.t("discourse_ai.errors.quota_required"))
37+
end
38+
end
39+
end
40+
41+
# == Schema Information
42+
#
43+
# Table name: llm_quotas
44+
#
45+
# id :bigint not null, primary key
46+
# group_id :bigint not null
47+
# llm_model_id :bigint not null
48+
# max_tokens :integer
49+
# max_usages :integer
50+
# duration_seconds :integer not null
51+
# created_at :datetime not null
52+
# updated_at :datetime not null
53+
#

app/models/llm_quota_usage.rb

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# frozen_string_literal: true
2+
3+
class LlmQuotaUsage < ActiveRecord::Base
4+
self.table_name = "llm_quota_usages"
5+
6+
QuotaExceededError = Class.new(StandardError)
7+
8+
belongs_to :user
9+
belongs_to :llm_quota
10+
11+
validates :user_id, presence: true
12+
validates :llm_quota_id, presence: true
13+
validates :input_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 }
14+
validates :output_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 }
15+
validates :usages, presence: true, numericality: { greater_than_or_equal_to: 0 }
16+
validates :started_at, presence: true
17+
validates :reset_at, presence: true
18+
19+
def self.find_or_create_for(user:, llm_quota:)
20+
usage = find_or_initialize_by(user: user, llm_quota: llm_quota)
21+
22+
if usage.new_record?
23+
now = Time.current
24+
usage.started_at = now
25+
usage.reset_at = now + llm_quota.duration_seconds.seconds
26+
usage.input_tokens_used = 0
27+
usage.output_tokens_used = 0
28+
usage.usages = 0
29+
usage.save!
30+
end
31+
32+
usage
33+
end
34+
35+
def reset_if_needed!
36+
return if Time.current < reset_at
37+
38+
now = Time.current
39+
update!(
40+
input_tokens_used: 0,
41+
output_tokens_used: 0,
42+
usages: 0,
43+
started_at: now,
44+
reset_at: now + llm_quota.duration_seconds.seconds,
45+
)
46+
end
47+
48+
def increment_usage!(input_tokens:, output_tokens:)
49+
reset_if_needed!
50+
51+
increment!(:usages)
52+
increment!(:input_tokens_used, input_tokens)
53+
increment!(:output_tokens_used, output_tokens)
54+
end
55+
56+
def check_quota!
57+
reset_if_needed!
58+
59+
if quota_exceeded?
60+
raise QuotaExceededError.new(
61+
I18n.t(
62+
"discourse_ai.errors.quota_exceeded",
63+
group: llm_quota.group.name,
64+
reset_at: reset_at,
65+
),
66+
)
67+
end
68+
end
69+
70+
def quota_exceeded?
71+
return false if !llm_quota
72+
73+
(llm_quota.max_tokens.present? && total_tokens_used > llm_quota.max_tokens) ||
74+
(llm_quota.max_usages.present? && usages > llm_quota.max_usages)
75+
end
76+
77+
def total_tokens_used
78+
input_tokens_used + output_tokens_used
79+
end
80+
81+
def remaining_tokens
82+
return nil if llm_quota.max_tokens.nil?
83+
[0, llm_quota.max_tokens - total_tokens_used].max
84+
end
85+
86+
def remaining_usages
87+
return nil if llm_quota.max_usages.nil?
88+
[0, llm_quota.max_usages - usages].max
89+
end
90+
91+
def percentage_tokens_used
92+
return 0 if llm_quota.max_tokens.nil? || llm_quota.max_tokens.zero?
93+
[(total_tokens_used.to_f / llm_quota.max_tokens * 100).round, 100].min
94+
end
95+
96+
def percentage_usages_used
97+
return 0 if llm_quota.max_usages.nil? || llm_quota.max_usages.zero?
98+
[(usages.to_f / llm_quota.max_usages * 100).round, 100].min
99+
end
100+
end
101+
102+
# == Schema Information
103+
#
104+
# Table name: llm_quota_usages
105+
#
106+
# id :bigint not null, primary key
107+
# user_id :bigint not null
108+
# llm_quota_id :bigint not null
109+
# input_tokens_used :integer not null
110+
# output_tokens_used:integer not null
111+
# usages :integer not null
112+
# started_at :datetime not null
113+
# reset_at :datetime not null
114+
# created_at :datetime not null
115+
# updated_at :datetime not null
116+
#

config/locales/server.en.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,8 @@ en:
446446
bedrock_invalid_url: "Please complete all the fields to use this model."
447447

448448
errors:
449+
quota_exceeded: "You have exceeded the quota for this model. Please try again after %{reset_at}."
450+
quota_required: "You must specify maximum tokens or usages for this model."
449451
no_query_specified: The query parameter is required, please specify it.
450452
no_user_for_persona: The persona specified does not have a user associated with it.
451453
persona_not_found: The persona specified does not exist. Check the persona_name or persona_id params.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
class AddLlmQuotaTables < ActiveRecord::Migration[7.2]
4+
def change
5+
create_table :llm_quotas do |t|
6+
t.bigint :group_id, null: false
7+
t.bigint :llm_model_id, null: false
8+
t.integer :max_tokens
9+
t.integer :max_usages
10+
t.integer :duration_seconds, null: false
11+
t.timestamps
12+
end
13+
14+
add_index :llm_quotas, :llm_model_id
15+
add_index :llm_quotas, %i[group_id llm_model_id], unique: true
16+
17+
create_table :llm_quota_usages do |t|
18+
t.bigint :user_id, null: false
19+
t.bigint :llm_quota_id, null: false
20+
t.integer :input_tokens_used, null: false
21+
t.integer :output_tokens_used, null: false
22+
t.integer :usages, null: false
23+
t.datetime :started_at, null: false
24+
t.datetime :reset_at, null: false
25+
t.timestamps
26+
end
27+
28+
add_index :llm_quota_usages, :llm_quota_id
29+
add_index :llm_quota_usages, %i[user_id llm_quota_id], unique: true
30+
end
31+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
Fabricator(:llm_quota) do
3+
group
4+
llm_model
5+
max_tokens { 1000 }
6+
max_usages { 10 }
7+
duration_seconds { 1.day.to_i }
8+
end
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# frozen_string_literal: true
2+
Fabricator(:llm_quota_usage) do
3+
user
4+
llm_quota
5+
input_tokens_used { 0 }
6+
output_tokens_used { 0 }
7+
usages { 0 }
8+
started_at { Time.current }
9+
reset_at { Time.current + 1.day }
10+
end

0 commit comments

Comments
 (0)