0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-11 02:59:07 +08:00
discourse/plugins/discourse-ai/spec/models/llm_quota_usage_spec.rb
Sam 6fc5673598
FEATURE: add cost limits to LLM quotas (#41277)
Track estimated request costs in AI audit logs and usage rollups so
quota
checks and usage reports can use the stored cost when available.

Add max cost quota fields to the admin UI, serializers, validations,
and staff logs.
2026-06-30 15:59:24 +10:00

372 lines
11 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.describe LlmQuotaUsage do
fab!(:group)
fab!(:user)
fab!(:llm_model)
fab!(:llm_quota) do
Fabricate(
:llm_quota,
group: group,
llm_model: llm_model,
max_tokens: 1000,
max_usages: 10,
duration_seconds: 1.day.to_i,
)
end
before { enable_current_plugin }
describe ".find_or_create_for" do
it "creates a new usage record if none exists" do
freeze_time
usage = described_class.find_or_create_for(user: user, llm_quota: llm_quota)
expect(usage).to be_persisted
expect(usage.started_at).to eq_time(Time.current)
expect(usage.reset_at).to eq_time(Time.current + llm_quota.duration_seconds.seconds)
expect(usage.input_tokens_used).to eq(0)
expect(usage.output_tokens_used).to eq(0)
expect(usage.cache_read_tokens_used).to eq(0)
expect(usage.cache_write_tokens_used).to eq(0)
expect(usage.cost_used).to eq(0)
expect(usage.usages).to eq(0)
end
it "returns existing usage record if one exists" do
existing = Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota)
usage = described_class.find_or_create_for(user: user, llm_quota: llm_quota)
expect(usage.id).to eq(existing.id)
end
it "handles concurrent creation correctly" do
threads = []
5.times do
threads << Thread.new do
described_class.find_or_create_for(user: user, llm_quota: llm_quota)
end
end
threads.each(&:join)
expect(described_class.where(user: user, llm_quota: llm_quota).count).to eq(1)
end
end
describe "#reset_if_needed!" do
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
it "resets usage when past reset_at" do
usage.update!(
input_tokens_used: 100,
output_tokens_used: 200,
cache_read_tokens_used: 300,
cache_write_tokens_used: 400,
cost_used: 0.12,
usages: 5,
reset_at: 1.minute.ago,
)
freeze_time
usage.reset_if_needed!
expect(usage.reload.input_tokens_used).to eq(0)
expect(usage.output_tokens_used).to eq(0)
expect(usage.cache_read_tokens_used).to eq(0)
expect(usage.cache_write_tokens_used).to eq(0)
expect(usage.cost_used).to eq(0)
expect(usage.usages).to eq(0)
expect(usage.started_at).to eq_time(Time.current)
expect(usage.reset_at).to eq_time(Time.current + llm_quota.duration_seconds.seconds)
end
it "doesn't reset if reset_at hasn't passed" do
freeze_time
original_values = {
input_tokens_used: 100,
output_tokens_used: 200,
cache_read_tokens_used: 300,
cache_write_tokens_used: 400,
cost_used: 0.12,
usages: 5,
reset_at: 1.minute.from_now,
}
usage.update!(original_values)
usage.reset_if_needed!
usage.reload
expect(usage.input_tokens_used).to eq(original_values[:input_tokens_used])
expect(usage.output_tokens_used).to eq(original_values[:output_tokens_used])
expect(usage.cache_read_tokens_used).to eq(original_values[:cache_read_tokens_used])
expect(usage.cache_write_tokens_used).to eq(original_values[:cache_write_tokens_used])
expect(usage.cost_used).to eq(original_values[:cost_used])
expect(usage.usages).to eq(original_values[:usages])
expect(usage.reset_at).to eq_time(original_values[:reset_at])
end
end
describe "#increment_usage!" do
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
it "increments usage counts" do
usage.increment_usage!(
input_tokens: 50,
output_tokens: 30,
cache_read_tokens: 20,
cache_write_tokens: 10,
cost: 0.123,
)
expect(usage.reload.input_tokens_used).to eq(50)
expect(usage.output_tokens_used).to eq(30)
expect(usage.cache_read_tokens_used).to eq(20)
expect(usage.cache_write_tokens_used).to eq(10)
expect(usage.cost_used).to eq(BigDecimal("0.123"))
expect(usage.usages).to eq(1)
end
it "accumulates multiple increments" do
2.times { usage.increment_usage!(input_tokens: 50, output_tokens: 30) }
expect(usage.reload.input_tokens_used).to eq(100)
expect(usage.output_tokens_used).to eq(60)
expect(usage.usages).to eq(2)
end
it "handles concurrent increments correctly" do
threads = []
5.times do
threads << Thread.new do
described_class.find(usage.id).increment_usage!(
input_tokens: 10,
output_tokens: 20,
cache_read_tokens: 30,
cache_write_tokens: 40,
cost: 0.50,
)
end
end
threads.each(&:join)
usage.reload
expect(usage.input_tokens_used).to eq(50)
expect(usage.output_tokens_used).to eq(100)
expect(usage.cache_read_tokens_used).to eq(150)
expect(usage.cache_write_tokens_used).to eq(200)
expect(usage.cost_used).to eq(BigDecimal("2.5"))
expect(usage.usages).to eq(5)
end
it "resets counts if needed before incrementing" do
usage.update!(
input_tokens_used: 100,
output_tokens_used: 200,
cache_read_tokens_used: 300,
cache_write_tokens_used: 400,
cost_used: 0.12,
usages: 5,
reset_at: 1.minute.ago,
)
usage.increment_usage!(input_tokens: 50, output_tokens: 30)
expect(usage.reload.input_tokens_used).to eq(50)
expect(usage.output_tokens_used).to eq(30)
expect(usage.cache_read_tokens_used).to eq(0)
expect(usage.cache_write_tokens_used).to eq(0)
expect(usage.cost_used).to eq(0)
expect(usage.usages).to eq(1)
end
end
describe "#check_quota!" do
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
it "doesn't raise error when within limits" do
expect { usage.check_quota! }.not_to raise_error
end
it "raises error when max_tokens exceeded" do
usage.update!(input_tokens_used: llm_quota.max_tokens + 1)
expect { usage.check_quota! }.to raise_error(LlmQuotaUsage::QuotaExceededError, /exceeded/)
end
it "raises error when max_usages exceeded" do
usage.update!(usages: llm_quota.max_usages + 1)
expect { usage.check_quota! }.to raise_error(LlmQuotaUsage::QuotaExceededError, /exceeded/)
end
it "raises error when max_cost exceeded" do
usage.llm_quota.update!(max_cost: 0.01)
usage.update!(cost_used: 0.02)
expect { usage.check_quota! }.to raise_error(LlmQuotaUsage::QuotaExceededError, /exceeded/)
end
it "resets quota if needed before checking" do
usage.update!(input_tokens_used: llm_quota.max_tokens + 1, reset_at: 1.minute.ago)
expect { usage.check_quota! }.not_to raise_error
expect(usage.reload.input_tokens_used).to eq(0)
end
end
describe "#quota_exceeded?" do
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
it "returns false when within limits" do
expect(usage.quota_exceeded?).to be false
end
it "returns true when max_tokens exceeded" do
usage.update!(input_tokens_used: llm_quota.max_tokens + 1)
expect(usage.quota_exceeded?).to be true
end
it "returns true when max_usages exceeded" do
usage.update!(usages: llm_quota.max_usages + 1)
expect(usage.quota_exceeded?).to be true
end
it "returns true when max_cost exceeded" do
usage.llm_quota.update!(max_cost: 0.01)
usage.update!(cost_used: 0.02)
expect(usage.quota_exceeded?).to be true
end
it "returns false when quota is nil" do
tokens = llm_quota.max_tokens + 1
usage.llm_quota.update!(max_tokens: nil)
usage.update!(input_tokens_used: tokens)
expect(usage.quota_exceeded?).to be false
end
end
describe "calculation methods" do
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
describe "#total_tokens_used" do
it "sums input and output tokens" do
usage.update!(input_tokens_used: 100, output_tokens_used: 200)
expect(usage.total_tokens_used).to eq(300)
end
end
describe "#remaining_tokens" do
it "calculates remaining tokens when under limit" do
usage.update!(input_tokens_used: 300, output_tokens_used: 200)
expect(usage.remaining_tokens).to eq(500)
end
it "returns 0 when over limit" do
usage.update!(input_tokens_used: 800, output_tokens_used: 300)
expect(usage.remaining_tokens).to eq(0)
end
it "returns nil when no max_tokens set" do
usage.llm_quota.update!(max_tokens: nil)
expect(usage.remaining_tokens).to be_nil
end
end
describe "#remaining_usages" do
it "calculates remaining usages when under limit" do
usage.update!(usages: 7)
expect(usage.remaining_usages).to eq(3)
end
it "returns 0 when over limit" do
usage.update!(usages: 15)
expect(usage.remaining_usages).to eq(0)
end
it "returns nil when no max_usages set" do
usage.llm_quota.update!(max_usages: nil)
expect(usage.remaining_usages).to be_nil
end
end
describe "#remaining_cost" do
it "calculates remaining cost when under limit" do
usage.llm_quota.update!(max_cost: 1.25)
usage.update!(cost_used: 0.25)
expect(usage.remaining_cost).to eq(BigDecimal("1.0"))
end
it "returns 0 when over limit" do
usage.llm_quota.update!(max_cost: 1.25)
usage.update!(cost_used: 1.50)
expect(usage.remaining_cost).to eq(0)
end
it "returns nil when no max_cost set" do
usage.llm_quota.update!(max_cost: nil)
expect(usage.remaining_cost).to be_nil
end
end
describe "#percentage_tokens_used" do
it "calculates percentage correctly" do
usage.update!(input_tokens_used: 250, output_tokens_used: 250)
expect(usage.percentage_tokens_used).to eq(50)
end
it "caps at 100%" do
usage.update!(input_tokens_used: 2000)
expect(usage.percentage_tokens_used).to eq(100)
end
it "returns 0 when no max_tokens set" do
usage.llm_quota.update!(max_tokens: nil)
expect(usage.percentage_tokens_used).to eq(0)
end
end
describe "#percentage_usages_used" do
it "calculates percentage correctly" do
usage.update!(usages: 5)
expect(usage.percentage_usages_used).to eq(50)
end
it "caps at 100%" do
usage.update!(usages: 20)
expect(usage.percentage_usages_used).to eq(100)
end
it "returns 0 when no max_usages set" do
usage.llm_quota.update!(max_usages: nil)
expect(usage.percentage_usages_used).to eq(0)
end
end
describe "#percentage_cost_used" do
it "calculates percentage correctly" do
usage.llm_quota.update!(max_cost: 1.00)
usage.update!(cost_used: 0.25)
expect(usage.percentage_cost_used).to eq(25)
end
it "caps at 100%" do
usage.llm_quota.update!(max_cost: 1.00)
usage.update!(cost_used: 2.00)
expect(usage.percentage_cost_used).to eq(100)
end
it "returns 0 when no max_cost set" do
usage.llm_quota.update!(max_cost: nil)
expect(usage.percentage_cost_used).to eq(0)
end
end
end
end