FEATURE: Hosted LLM credit system (#35162)

## 🔍 Overview
This update adds a credit system under the hood which will be used for
our CDCK Hosted LLM models so we can make our features more accessible
to our hosted customers!

## 📷 Screenshots
<img width="1105" height="268" alt="Screenshot 2025-10-02 at 12 48 58"
src="https://github.com/user-attachments/assets/2a07d89b-7510-4565-82bb-26b46fbcf5c4"
/>

_☝🏽 ` ProblemCheck` notices to inform customers_

<img width="1077" height="472" alt="Screenshot 2025-10-02 at 12 49 41"
src="https://github.com/user-attachments/assets/b72028f7-5df2-45a8-8c71-65cf750755ab"
/>

_☝🏽 AI Usage page for easy monitoring_

<img width="1112" height="1083" alt="Screenshot 2025-10-02 at 18 17 01"
src="https://github.com/user-attachments/assets/a01992d5-15a0-472a-9501-bc3bc9a54ade"
/>

_☝🏽 Credit bars underneath relevant LLM models_

<img width="866" height="267" alt="Screenshot 2025-10-03 at 11 35 19"
src="https://github.com/user-attachments/assets/e7b4c0e7-c93d-4b0f-923d-79ac5d53028b"
/>

_☝🏽 Dialog box when trying to use without available credits_
This commit is contained in:
Keegan George
2025-10-14 07:48:20 -07:00
committed by GitHub
parent 7d4f756a4f
commit 902fd7494b
53 changed files with 2201 additions and 47 deletions
@@ -0,0 +1,30 @@
# frozen_string_literal: true
module AiCreditLimitHandler
extend ActiveSupport::Concern
included do
rescue_from LlmCreditAllocation::CreditLimitExceeded do |e|
render_credit_limit_error(e)
end
end
private
def render_credit_limit_error(exception)
allocation = exception.allocation
details = {}
if allocation
details[:reset_time_relative] = allocation.relative_reset_time
details[:reset_time_absolute] = allocation.formatted_reset_time
end
render json: {
error: "credit_limit_exceeded",
message: exception.message,
details: details,
},
status: :too_many_requests
end
end
@@ -80,6 +80,9 @@ module DiscourseAi
end
end
handle_credit_allocation_update(llm_model)
handle_feature_credit_costs_update(llm_model)
if llm_model.seeded?
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
end
@@ -166,6 +169,24 @@ module DiscourseAi
end
end
def credit_allocation_params
return nil if params[:ai_llm][:llm_credit_allocation].blank?
allocation = params[:ai_llm][:llm_credit_allocation]
{
monthly_credits: allocation[:monthly_credits].to_i,
soft_limit_percentage: allocation[:soft_limit_percentage].to_i,
}
end
def feature_credit_cost_params
return nil if params[:ai_llm][:llm_feature_credit_costs].blank?
params[:ai_llm][:llm_feature_credit_costs].map do |cost|
{ feature_name: cost[:feature_name], credits_per_token: cost[:credits_per_token].to_f }
end
end
def ai_llm_params(updating: nil)
return {} if params[:ai_llm].blank?
@@ -292,6 +313,42 @@ module DiscourseAi
model_details[:subject] = model_details[:display_name]
logger.log_deletion("llm_model", model_details)
end
def handle_credit_allocation_update(llm_model)
return unless llm_model.seeded? && params[:ai_llm].key?(:llm_credit_allocation)
if credit_allocation_params
allocation =
llm_model.llm_credit_allocation ||
llm_model.build_llm_credit_allocation(last_reset_at: Time.current)
allocation.update!(credit_allocation_params)
elsif llm_model.llm_credit_allocation
llm_model.llm_credit_allocation.destroy
end
end
def handle_feature_credit_costs_update(llm_model)
return unless llm_model.seeded? && params[:ai_llm].key?(:llm_feature_credit_costs)
if feature_credit_cost_params
existing_costs = llm_model.llm_feature_credit_costs.index_by(&:feature_name)
new_features = feature_credit_cost_params.map { |c| c[:feature_name] }
llm_model
.llm_feature_credit_costs
.where(feature_name: existing_costs.keys - new_features)
.destroy_all
feature_credit_cost_params.each do |cost_param|
cost =
existing_costs[cost_param[:feature_name]] ||
llm_model.llm_feature_credit_costs.build(feature_name: cost_param[:feature_name])
cost.update!(cost_param)
end
else
llm_model.llm_feature_credit_costs.destroy_all
end
end
end
end
end
@@ -3,6 +3,8 @@
module DiscourseAi
module AiBot
class BotController < ::ApplicationController
include AiCreditLimitHandler
requires_plugin PLUGIN_NAME
requires_login
@@ -3,6 +3,8 @@
module DiscourseAi
module AiBot
class ConversationsController < ::ApplicationController
include AiCreditLimitHandler
requires_plugin PLUGIN_NAME
requires_login
@@ -3,6 +3,8 @@
module DiscourseAi
module AiHelper
class AssistantController < ::ApplicationController
include AiCreditLimitHandler
requires_plugin PLUGIN_NAME
requires_login
before_action :ensure_can_request_suggestions
@@ -3,6 +3,8 @@
module DiscourseAi
module Discover
class DiscoveriesController < ::ApplicationController
include AiCreditLimitHandler
requires_plugin PLUGIN_NAME
requires_login
@@ -3,6 +3,8 @@
module DiscourseAi
module Summarization
class SummaryController < ::ApplicationController
include AiCreditLimitHandler
requires_plugin PLUGIN_NAME
def show
@@ -3,6 +3,8 @@
module DiscourseAi
module Translation
class TranslationController < ::ApplicationController
include AiCreditLimitHandler
requires_plugin PLUGIN_NAME
before_action :ensure_logged_in
@@ -13,15 +13,41 @@ module Jobs
helper_mode = args[:prompt]
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
helper_mode,
args[:text],
user,
args[:progress_channel],
force_default_locale: args[:force_default_locale],
client_id: args[:client_id],
custom_prompt: args[:custom_prompt],
)
begin
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
helper_mode,
args[:text],
user,
args[:progress_channel],
force_default_locale: args[:force_default_locale],
client_id: args[:client_id],
custom_prompt: args[:custom_prompt],
)
rescue LlmCreditAllocation::CreditLimitExceeded => e
publish_error(args[:progress_channel], user, e)
end
end
private
def publish_error(channel, user, exception)
allocation = exception.allocation
details = {}
if allocation
details[:reset_time_relative] = allocation.relative_reset_time
details[:reset_time_absolute] = allocation.formatted_reset_time
end
payload = {
error: true,
error_type: "credit_limit_exceeded",
message: exception.message,
details: details,
done: true,
}
MessageBus.publish(channel, payload, user_ids: [user.id], max_backlog_age: 60)
end
end
end
@@ -40,22 +40,46 @@ module Jobs
feature_name: "discover",
)
bot.reply(context) do |partial|
streamed_reply << partial
begin
bot.reply(context) do |partial|
streamed_reply << partial
# Throttle updates.
if (Time.now - start > 0.3) || Rails.env.test?
payload = base.merge(done: false, ai_discover_reply: streamed_reply)
publish_update(user, payload)
start = Time.now
# Throttle updates.
if (Time.now - start > 0.3) || Rails.env.test?
payload = base.merge(done: false, ai_discover_reply: streamed_reply)
publish_update(user, payload)
start = Time.now
end
end
end
publish_update(user, base.merge(done: true, ai_discover_reply: streamed_reply))
publish_update(user, base.merge(done: true, ai_discover_reply: streamed_reply))
rescue LlmCreditAllocation::CreditLimitExceeded => e
publish_error_update(user, e)
end
end
def publish_update(user, payload)
MessageBus.publish("/discourse-ai/discoveries", payload, user_ids: [user.id])
end
def publish_error_update(user, exception)
allocation = exception.allocation
details = {}
if allocation
details[:reset_time_relative] = allocation.relative_reset_time
details[:reset_time_absolute] = allocation.formatted_reset_time
end
payload = {
error: true,
error_type: "credit_limit_exceeded",
message: exception.message,
details: details,
done: true,
}
MessageBus.publish("/discourse-ai/discoveries", payload, user_ids: [user.id])
end
end
end
@@ -29,14 +29,40 @@ module Jobs
input = args[:text]
end
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
helper_mode,
input,
user,
args[:progress_channel],
custom_prompt: args[:custom_prompt],
client_id: args[:client_id],
)
begin
DiscourseAi::AiHelper::Assistant.new.stream_prompt(
helper_mode,
input,
user,
args[:progress_channel],
custom_prompt: args[:custom_prompt],
client_id: args[:client_id],
)
rescue LlmCreditAllocation::CreditLimitExceeded => e
publish_error(args[:progress_channel], user, e)
end
end
private
def publish_error(channel, user, exception)
allocation = exception.allocation
details = {}
if allocation
details[:reset_time_relative] = allocation.relative_reset_time
details[:reset_time_absolute] = allocation.formatted_reset_time
end
payload = {
error: true,
error_type: "credit_limit_exceeded",
message: exception.message,
details: details,
done: true,
}
MessageBus.publish(channel, payload, user_ids: [user.id], max_backlog_age: 60)
end
end
end
@@ -19,26 +19,30 @@ module Jobs
streamed_summary = +""
start = Time.now
summary =
DiscourseAi::TopicSummarization
.new(strategy, user)
.summarize(skip_age_check: skip_age_check) do |partial_summary|
streamed_summary << partial_summary
begin
summary =
DiscourseAi::TopicSummarization
.new(strategy, user)
.summarize(skip_age_check: skip_age_check) do |partial_summary|
streamed_summary << partial_summary
# Throttle updates.
if (Time.now - start > 0.3) || Rails.env.test?
payload = { done: false, ai_topic_summary: { summarized_text: streamed_summary } }
# Throttle updates.
if (Time.now - start > 0.3) || Rails.env.test?
payload = { done: false, ai_topic_summary: { summarized_text: streamed_summary } }
publish_update(topic, user, payload)
start = Time.now
publish_update(topic, user, payload)
start = Time.now
end
end
end
publish_update(
topic,
user,
AiTopicSummarySerializer.new(summary, { scope: guardian }).as_json.merge(done: true),
)
publish_update(
topic,
user,
AiTopicSummarySerializer.new(summary, { scope: guardian }).as_json.merge(done: true),
)
rescue LlmCreditAllocation::CreditLimitExceeded => e
publish_error_update(topic, user, e)
end
end
private
@@ -46,5 +50,25 @@ module Jobs
def publish_update(topic, user, payload)
MessageBus.publish("/discourse-ai/summaries/topic/#{topic.id}", payload, user_ids: [user.id])
end
def publish_error_update(topic, user, exception)
allocation = exception.allocation
details = {}
if allocation
details[:reset_time_relative] = allocation.relative_reset_time
details[:reset_time_absolute] = allocation.formatted_reset_time
end
payload = {
error: true,
error_type: "credit_limit_exceeded",
message: exception.message,
details: details,
done: true,
}
MessageBus.publish("/discourse-ai/summaries/topic/#{topic.id}", payload, user_ids: [user.id])
end
end
end
@@ -9,6 +9,16 @@ module Jobs
def execute(args)
return if !DiscourseAi::Translation.backfill_enabled?
llm_model = find_llm_model
return if llm_model.blank?
unless LlmCreditAllocation.credits_available?(llm_model)
Rails.logger.info(
"Categories locale detection backfill skipped: insufficient credits. Will resume when credits reset.",
)
return
end
categories = Category.where(locale: nil)
if SiteSetting.ai_translation_backfill_limit_to_public_content
@@ -32,5 +42,15 @@ module Jobs
DiscourseAi::Translation::VerboseLogger.log("Detected #{categories.size} category locales")
end
private
def find_llm_model
ai_persona = AiPersona.find_by(id: SiteSetting.ai_translation_locale_detector_persona)
return nil if ai_persona.blank?
persona_klass = ai_persona.class_instance
DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass)
end
end
end
@@ -7,9 +7,35 @@ module Jobs
def execute(args)
return if !DiscourseAi::Translation.backfill_enabled?
short_text_llm =
find_llm_model_for_persona(SiteSetting.ai_translation_short_text_translator_persona)
post_raw_llm =
find_llm_model_for_persona(SiteSetting.ai_translation_post_raw_translator_persona)
if (short_text_llm && !LlmCreditAllocation.credits_available?(short_text_llm)) ||
(post_raw_llm && !LlmCreditAllocation.credits_available?(post_raw_llm))
Rails.logger.info(
"Category localization backfill skipped: insufficient credits. Will resume when credits reset.",
)
return
end
limit = SiteSetting.ai_translation_backfill_hourly_rate
Jobs.enqueue(:localize_categories, limit:)
end
private
def find_llm_model_for_persona(persona_id)
return nil if persona_id.blank?
ai_persona = AiPersona.find_by(id: persona_id)
return nil if ai_persona.blank?
persona_klass = ai_persona.class_instance
DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass)
end
end
end
@@ -8,10 +8,32 @@ module Jobs
def execute(args)
return if !DiscourseAi::Translation.backfill_enabled?
post_raw_llm =
find_llm_model_for_persona(SiteSetting.ai_translation_post_raw_translator_persona)
if post_raw_llm && !LlmCreditAllocation.credits_available?(post_raw_llm)
Rails.logger.info(
"Post localization backfill skipped: insufficient credits. Will resume when credits reset.",
)
return
end
limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
return if limit == 0
Jobs.enqueue(:localize_posts, limit:)
end
private
def find_llm_model_for_persona(persona_id)
return nil if persona_id.blank?
ai_persona = AiPersona.find_by(id: persona_id)
return nil if ai_persona.blank?
persona_klass = ai_persona.class_instance
DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass)
end
end
end
@@ -9,6 +9,16 @@ module Jobs
def execute(args)
return if !DiscourseAi::Translation.backfill_enabled?
llm_model = find_llm_model
return if llm_model.blank?
unless LlmCreditAllocation.credits_available?(llm_model)
Rails.logger.info(
"Posts locale detection backfill skipped: insufficient credits. Will resume when credits reset.",
)
return
end
limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
posts =
@@ -34,5 +44,15 @@ module Jobs
DiscourseAi::Translation::VerboseLogger.log("Detected #{posts.size} post locales")
end
private
def find_llm_model
ai_persona = AiPersona.find_by(id: SiteSetting.ai_translation_locale_detector_persona)
return nil if ai_persona.blank?
persona_klass = ai_persona.class_instance
DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass)
end
end
end
@@ -10,6 +10,16 @@ module Jobs
return if !SiteSetting.ai_summarization_enabled
return if SiteSetting.ai_summary_backfill_maximum_topics_per_hour.zero?
llm_model = find_llm_model
return if llm_model.blank?
unless LlmCreditAllocation.credits_available?(llm_model)
Rails.logger.info(
"Summaries backfill skipped: insufficient credits. Will resume when credits reset.",
)
return
end
system_user = Discourse.system_user
if SiteSetting.ai_summary_gists_enabled
@@ -87,5 +97,13 @@ module Jobs
current_budget
end
def find_llm_model
ai_persona = AiPersona.find_by(id: SiteSetting.ai_summarization_persona)
return nil if ai_persona.blank?
persona_klass = ai_persona.class_instance
DiscourseAi::Summarization.find_summarization_model(persona_klass)
end
end
end
@@ -8,8 +8,33 @@ module Jobs
def execute(args)
return if !DiscourseAi::Translation.backfill_enabled?
topic_title_llm =
find_llm_model_for_persona(SiteSetting.ai_translation_topic_title_translator_persona)
post_raw_llm =
find_llm_model_for_persona(SiteSetting.ai_translation_post_raw_translator_persona)
if (topic_title_llm && !LlmCreditAllocation.credits_available?(topic_title_llm)) ||
(post_raw_llm && !LlmCreditAllocation.credits_available?(post_raw_llm))
Rails.logger.info(
"Topic localization backfill skipped: insufficient credits. Will resume when credits reset.",
)
return
end
limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
Jobs.enqueue(:localize_topics, limit:)
end
private
def find_llm_model_for_persona(persona_id)
return nil if persona_id.blank?
ai_persona = AiPersona.find_by(id: persona_id)
return nil if ai_persona.blank?
persona_klass = ai_persona.class_instance
DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass)
end
end
end
@@ -9,6 +9,16 @@ module Jobs
def execute(args)
return if !DiscourseAi::Translation.backfill_enabled?
llm_model = find_llm_model
return if llm_model.blank?
unless LlmCreditAllocation.credits_available?(llm_model)
Rails.logger.info(
"Topics locale detection backfill skipped: insufficient credits. Will resume when credits reset.",
)
return
end
limit = SiteSetting.ai_translation_backfill_hourly_rate / (60 / 5) # this job runs in 5-minute intervals
topics =
@@ -34,5 +44,15 @@ module Jobs
DiscourseAi::Translation::VerboseLogger.log("Detected #{topics.size} topic locales")
end
private
def find_llm_model
ai_persona = AiPersona.find_by(id: SiteSetting.ai_translation_locale_detector_persona)
return nil if ai_persona.blank?
persona_klass = ai_persona.class_instance
DiscourseAi::Translation::BaseTranslator.preferred_llm_model(persona_klass)
end
end
end
@@ -0,0 +1,173 @@
# frozen_string_literal: true
class LlmCreditAllocation < ActiveRecord::Base
self.table_name = "llm_credit_allocations"
class CreditLimitExceeded < StandardError
attr_reader :allocation
def initialize(message, allocation: nil)
super(message)
@allocation = allocation
end
end
belongs_to :llm_model
validates :llm_model_id, presence: true, uniqueness: true
validates :monthly_credits, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :monthly_used,
presence: true,
numericality: {
only_integer: true,
greater_than_or_equal_to: 0,
}
validates :soft_limit_percentage,
presence: true,
numericality: {
only_integer: true,
greater_than_or_equal_to: 0,
less_than_or_equal_to: 100,
}
validates :last_reset_at, presence: true
before_validation :set_last_reset_at, on: :create
def credits_remaining
[0, monthly_credits - monthly_used].max
end
def percentage_used
return 0 if monthly_credits.zero?
[(monthly_used.to_f / monthly_credits * 100).round(2), 100].min
end
def percentage_remaining
return 100.0 if monthly_credits.zero?
[(credits_remaining.to_f / monthly_credits * 100).round(2), 0].max
end
def soft_limit_reached?
percentage_used >= soft_limit_percentage
end
def soft_limit_remaining_reached?
percentage_remaining <= (100 - soft_limit_percentage)
end
def hard_limit_reached?
monthly_used >= monthly_credits
end
def hard_limit_remaining_reached?
credits_remaining <= 0
end
def next_reset_at
return nil if last_reset_at.nil?
last_reset_at + 1.month
end
def reset_if_needed!
with_lock do
reload
return unless should_reset?
now = Time.current
update!(monthly_used: 0, last_reset_at: now)
end
end
def should_reset?
return false if last_reset_at.nil?
Time.current >= next_reset_at
end
def deduct_credits!(credits)
with_lock do
self.monthly_used += credits
save!
end
end
def credits_available?
!hard_limit_reached?
end
def check_credits!
if hard_limit_reached?
raise CreditLimitExceeded.new(
I18n.t(
"discourse_ai.llm_credit_allocation.limit_exceeded",
reset_time: format_reset_time,
),
allocation: self,
)
end
end
def self.credits_available?(llm_model)
return true unless llm_model&.credit_system_enabled?
allocation = llm_model.llm_credit_allocation
return true unless allocation
allocation.reset_if_needed!
allocation.credits_available?
end
def self.check_credits!(llm_model)
return unless llm_model&.credit_system_enabled?
allocation = llm_model.llm_credit_allocation
allocation.reset_if_needed!
allocation.check_credits!
end
def self.deduct_credits!(llm_model, feature_name, request_tokens, response_tokens)
return unless llm_model&.credit_system_enabled?
total_tokens = request_tokens + response_tokens
credit_cost = LlmFeatureCreditCost.calculate_credit_cost(llm_model, feature_name, total_tokens)
llm_model.llm_credit_allocation.deduct_credits!(credit_cost)
end
def formatted_reset_time
return "" if next_reset_at.nil?
next_reset_at.strftime("%l:%M%P on %b %d, %Y").strip
end
def relative_reset_time
return "" if next_reset_at.nil?
"in " + AgeWords.distance_of_time_in_words(Time.current, next_reset_at)
end
private
def set_last_reset_at
self.last_reset_at ||= Time.current
end
def format_reset_time
return "" if next_reset_at.nil?
AgeWords.distance_of_time_in_words(next_reset_at, Time.now)
end
end
# == Schema Information
#
# Table name: llm_credit_allocations
#
# id :bigint not null, primary key
# last_reset_at :datetime not null
# monthly_credits :bigint not null
# monthly_used :bigint default(0), not null
# soft_limit_percentage :integer default(80), not null
# created_at :datetime not null
# updated_at :datetime not null
# llm_model_id :bigint not null
#
# Indexes
#
# index_llm_credit_allocations_on_llm_model_id (llm_model_id) UNIQUE
#
@@ -0,0 +1,44 @@
# frozen_string_literal: true
class LlmFeatureCreditCost < ActiveRecord::Base
self.table_name = "llm_feature_credit_costs"
belongs_to :llm_model
validates :llm_model_id, presence: true
validates :feature_name, presence: true
validates :credits_per_token, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :feature_name, uniqueness: { scope: :llm_model_id }
def self.credit_cost_for(llm_model, feature_name)
return 1.0 if llm_model.blank? || feature_name.blank?
cost =
where(llm_model: llm_model, feature_name: feature_name).pick(:credits_per_token) ||
where(llm_model: llm_model, feature_name: "default").pick(:credits_per_token) || 1.0
cost.to_f
end
def self.calculate_credit_cost(llm_model, feature_name, total_tokens)
cost_per_token = credit_cost_for(llm_model, feature_name)
(total_tokens * cost_per_token).ceil
end
end
# == Schema Information
#
# Table name: llm_feature_credit_costs
#
# id :bigint not null, primary key
# credits_per_token :decimal(10, 4) default(1.0), not null
# feature_name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# llm_model_id :bigint not null
#
# Indexes
#
# idx_on_llm_model_id_feature_name_2b0b794b27 (llm_model_id,feature_name) UNIQUE
# index_llm_feature_credit_costs_on_llm_model_id (llm_model_id)
#
@@ -5,6 +5,8 @@ class LlmModel < ActiveRecord::Base
BEDROCK_PROVIDER_NAME = "aws_bedrock"
has_many :llm_quotas, dependent: :destroy
has_one :llm_credit_allocation, dependent: :destroy
has_many :llm_feature_credit_costs, dependent: :destroy
belongs_to :user
validates :display_name, presence: true, length: { maximum: 100 }
@@ -182,6 +184,10 @@ class LlmModel < ActiveRecord::Base
end
end
def credit_system_enabled?
seeded? && llm_credit_allocation.present?
end
private
def required_provider_params
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class LlmCreditAllocationSerializer < ApplicationSerializer
attributes :id,
:monthly_credits,
:monthly_used,
:credits_remaining,
:percentage_used,
:percentage_remaining,
:last_reset_at,
:next_reset_at,
:soft_limit_percentage,
:soft_limit_reached,
:hard_limit_reached
def soft_limit_reached
object.soft_limit_reached?
end
def hard_limit_reached
object.hard_limit_reached?
end
end
@@ -0,0 +1,5 @@
# frozen_string_literal: true
class LlmFeatureCreditCostSerializer < ApplicationSerializer
attributes :id, :feature_name, :credits_per_token
end
@@ -25,6 +25,14 @@ class LlmModelSerializer < ApplicationSerializer
has_one :user, serializer: BasicUserSerializer, embed: :object
has_many :llm_quotas, serializer: LlmQuotaSerializer, embed: :objects
has_one :llm_credit_allocation,
serializer: LlmCreditAllocationSerializer,
embed: :object,
if: :include_credit_allocation?
has_many :llm_feature_credit_costs,
serializer: LlmFeatureCreditCostSerializer,
embed: :objects,
if: :include_credit_allocation?
def used_by
llm_usage =
@@ -50,4 +58,8 @@ class LlmModelSerializer < ApplicationSerializer
def provider
object.seeded? ? "CDCK" : object.provider
end
def include_credit_allocation?
object.seeded?
end
end
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class ProblemCheck::AiCreditHardLimit < ProblemCheck
self.priority = "high"
self.perform_every = 1.hour
def call
return [] if !SiteSetting.discourse_ai_enabled
problems = []
LlmModel
.where("id < 0")
.includes(:llm_credit_allocation)
.find_each do |model|
next unless model.llm_credit_allocation
allocation = model.llm_credit_allocation
allocation.reset_if_needed!
problems << hard_limit_problem(model, allocation) if allocation.hard_limit_reached?
end
problems.compact
end
private
def hard_limit_problem(model, allocation)
details = {
model_id: model.id,
model_name: model.display_name,
reset_date: format_reset_date(allocation.next_reset_at),
url: "#{Discourse.base_path}/admin/plugins/discourse-ai/ai-llms",
}
message = I18n.t("dashboard.problem.ai_credit_hard_limit", details)
Problem.new(
message,
priority: "high",
identifier: "ai_credit_hard_limit",
target: model.id,
details:,
)
end
def format_reset_date(date)
I18n.l(date, format: :long)
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
class ProblemCheck::AiCreditSoftLimit < ProblemCheck
self.priority = "low"
self.perform_every = 1.hour
def call
return [] if !SiteSetting.discourse_ai_enabled
problems = []
LlmModel
.where("id < 0")
.includes(:llm_credit_allocation)
.find_each do |model|
next unless model.llm_credit_allocation
allocation = model.llm_credit_allocation
allocation.reset_if_needed!
if allocation.soft_limit_reached? && !allocation.hard_limit_reached?
problems << soft_limit_problem(model, allocation)
end
end
problems.compact
end
private
def soft_limit_problem(model, allocation)
details = {
model_id: model.id,
model_name: model.display_name,
percentage_remaining: allocation.percentage_remaining.round,
reset_date: format_reset_date(allocation.next_reset_at),
url: "#{Discourse.base_path}/admin/plugins/discourse-ai/ai-llms",
}
message = I18n.t("dashboard.problem.ai_credit_soft_limit", details)
Problem.new(
message,
priority: "low",
identifier: "ai_credit_soft_limit",
target: model.id,
details:,
)
end
def format_reset_date(date)
I18n.l(date, format: :long)
end
end
@@ -0,0 +1,79 @@
import Component from "@glimmer/component";
import { htmlSafe } from "@ember/template";
import concatClass from "discourse/helpers/concat-class";
import { number } from "discourse/lib/formatter";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
/**
* Component to display credit allocation remaining as a horizontal progress bar
*
* @component AiCreditBar
* @param {Object} allocation - LlmCreditAllocation object with monthly_credits, credits_remaining, percentage_remaining, soft_limit_reached, hard_limit_reached, next_reset_at
* @param {Boolean} showTooltip - Whether to show tooltip on hover (default: true)
*/
export default class AiCreditBar extends Component {
get barClass() {
if (this.args.allocation.soft_limit_reached) {
return "ai-credit-bar--warning";
}
return "";
}
get fillStyle() {
return htmlSafe(`width: ${this.args.allocation.percentage_remaining}%`);
}
get barText() {
return i18n("discourse_ai.llms.credit_allocation.credits_remaining", {
remaining: number(this.args.allocation.credits_remaining),
total: number(this.args.allocation.monthly_credits),
percentage: this.args.allocation.percentage_remaining,
});
}
get tooltipText() {
const resetDate = new Date(this.args.allocation.next_reset_at);
const options = {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
};
const formattedDate = resetDate.toLocaleString(undefined, options);
return i18n("discourse_ai.llms.credit_allocation.next_reset", {
time: formattedDate,
});
}
get shouldShowTooltip() {
return this.args.showTooltip !== false;
}
<template>
{{#if this.shouldShowTooltip}}
<DTooltip @content={{this.tooltipText}}>
<:trigger>
<div class={{concatClass "ai-credit-bar" this.barClass}}>
<div class="ai-credit-bar__progress">
<div class="ai-credit-bar__fill" style={{this.fillStyle}}></div>
</div>
<div class="ai-credit-bar__text">
{{this.barText}}
</div>
</div>
</:trigger>
</DTooltip>
{{else}}
<div class={{concatClass "ai-credit-bar" this.barClass}}>
<div class="ai-credit-bar__progress">
<div class="ai-credit-bar__fill" style={{this.fillStyle}}></div>
</div>
<div class="ai-credit-bar__text">
{{this.barText}}
</div>
</div>
{{/if}}
</template>
}
@@ -5,10 +5,12 @@ import { service } from "@ember/service";
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
import DButton from "discourse/components/d-button";
import DPageSubheader from "discourse/components/d-page-subheader";
import icon from "discourse/helpers/d-icon";
import I18n, { i18n } from "discourse-i18n";
import AdminSectionLandingItem from "admin/components/admin-section-landing-item";
import AdminSectionLandingWrapper from "admin/components/admin-section-landing-wrapper";
import DTooltip from "float-kit/components/d-tooltip";
import AiCreditBar from "./ai-credit-bar";
import AiDefaultLlmSelector from "./ai-default-llm-selector";
import AiLlmEditor from "./ai-llm-editor";
@@ -22,6 +24,17 @@ export default class AiLlmsListEditor extends Component {
@service adminPluginNavManager;
@service router;
formatResetDate(dateString) {
const resetDate = new Date(dateString);
const options = {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
};
return resetDate.toLocaleString(undefined, options);
}
@action
modelDescription(llm) {
// this is a bit of an odd object, it can be an llm model or a preset model
@@ -178,6 +191,34 @@ export default class AiLlmsListEditor extends Component {
{{/each}}
</ul>
{{/if}}
{{#if llm.llm_credit_allocation}}
<div class="ai-llm-list__credit-allocation">
<AiCreditBar
@allocation={{llm.llm_credit_allocation}}
/>
{{#if llm.llm_credit_allocation.hard_limit_reached}}
<div class="alert alert-danger ai-credit-warning">
{{icon "circle-info"}}
{{i18n
"discourse_ai.llms.credit_allocation.hard_limit_warning"
reset_date=(this.formatResetDate
llm.llm_credit_allocation.next_reset_at
)
}}
</div>
{{else if
llm.llm_credit_allocation.soft_limit_reached
}}
<div class="alert alert-warning ai-credit-warning">
{{icon "circle-info"}}
{{i18n
"discourse_ai.llms.credit_allocation.soft_limit_warning"
percentage=llm.llm_credit_allocation.percentage_remaining
}}
</div>
{{/if}}
</div>
{{/if}}
</td>
<td class="d-admin-row__detail">
<div class="d-admin-row__mobile-label">
@@ -18,6 +18,10 @@ import { clipboardCopy } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import AiHelperLoading from "../components/ai-helper-loading";
import AiHelperOptionsList from "../components/ai-helper-options-list";
import {
isAiCreditLimitError,
popupAiCreditLimitError,
} from "../lib/ai-errors";
import SmoothStreamer from "../lib/smooth-streamer";
export default class AiPostHelperMenu extends Component {
@@ -168,6 +172,13 @@ export default class AiPostHelperMenu extends Component {
@bind
async _updateResult(result) {
if (isAiCreditLimitError(result)) {
this.loading = false;
this.menuState = this.MENU_STATES.triggers;
popupAiCreditLimitError(result);
return;
}
this.streaming = !result.done;
await this.smoothStreamer.updateResult(result, "result");
}
@@ -212,7 +223,13 @@ export default class AiPostHelperMenu extends Component {
this.menuState = this.MENU_STATES.result;
});
} catch (error) {
popupAjaxError(error);
if (isAiCreditLimitError(error)) {
popupAiCreditLimitError(error);
} else {
popupAjaxError(error);
}
this.loading = false;
this.menuState = this.MENU_STATES.triggers;
}
return this._activeAiRequest;
@@ -315,7 +332,11 @@ export default class AiPostHelperMenu extends Component {
await this.args.data.post.save({ raw: newRaw });
} catch (error) {
popupAjaxError(error);
if (isAiCreditLimitError(error)) {
popupAiCreditLimitError(error);
} else {
popupAjaxError(error);
}
} finally {
this.isSavingFootnote = false;
await this.closeMenu();
@@ -22,9 +22,11 @@ import AdminConfigAreaCard from "admin/components/admin-config-area-card";
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
import Chart from "admin/components/chart";
import ComboBox from "select-kit/components/combo-box";
import AiCreditBar from "./ai-credit-bar";
export default class AiUsage extends Component {
@service currentUser;
@service store;
@tracked startDate = moment().subtract(30, "days").toDate();
@tracked endDate = new Date();
@@ -34,10 +36,36 @@ export default class AiUsage extends Component {
@tracked selectedPeriod = "month";
@tracked isCustomDateActive = false;
@tracked loadingData = true;
@tracked llmsWithCredits = [];
constructor() {
super(...arguments);
this.fetchData();
this.fetchLlmsWithCredits();
}
formatResetDate(dateString) {
const resetDate = new Date(dateString);
const options = {
month: "long",
day: "numeric",
hour: "numeric",
minute: "2-digit",
};
return resetDate.toLocaleString(undefined, options);
}
@action
async fetchLlmsWithCredits() {
try {
const llms = await this.store.findAll("ai-llm");
this.llmsWithCredits = llms.filter(
(llm) => llm.llm_credit_allocation != null
);
} catch {
// Silently fail if LLMs can't be loaded
this.llmsWithCredits = [];
}
}
@action
@@ -653,6 +681,33 @@ export default class AiUsage extends Component {
</:content>
</AdminConfigAreaCard>
</div>
{{#if this.llmsWithCredits.length}}
<AdminConfigAreaCard
class="ai-usage__credit-allocations"
@heading="discourse_ai.usage.credit_allocations"
>
<:content>
{{#each this.llmsWithCredits as |llm|}}
<div class="ai-usage__credit-model">
<h4>{{llm.display_name}}</h4>
<AiCreditBar
@allocation={{llm.llm_credit_allocation}}
@showTooltip={{false}}
/>
<div class="ai-usage__credit-details">
<span>{{i18n
"discourse_ai.llms.credit_allocation.next_reset"
time=(this.formatResetDate
llm.llm_credit_allocation.next_reset_at
)
}}</span>
</div>
</div>
{{/each}}
</:content>
</AdminConfigAreaCard>
{{/if}}
</ConditionalLoadingSpinner>
</div>
</div>
@@ -19,6 +19,10 @@ import { shortDateNoYear } from "discourse/lib/formatter";
import { i18n } from "discourse-i18n";
import DTooltip from "float-kit/components/d-tooltip";
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
import {
isAiCreditLimitError,
popupAiCreditLimitError,
} from "../../lib/ai-errors";
import SmoothStreamer from "../../lib/smooth-streamer";
export default class AiSummaryModal extends Component {
@@ -141,6 +145,13 @@ export default class AiSummaryModal extends Component {
@bind
async _updateSummary(update) {
if (isAiCreditLimitError(update)) {
this.loading = false;
popupAiCreditLimitError(update);
this.unsubscribe();
return;
}
const topicSummary = {
done: update.done,
raw: update.ai_topic_summary?.summarized_text,
@@ -14,6 +14,10 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { bind } from "discourse/lib/decorators";
import { escapeExpression } from "discourse/lib/utilities";
import { i18n } from "discourse-i18n";
import {
isAiCreditLimitError,
popupAiCreditLimitError,
} from "../../lib/ai-errors";
import DiffStreamer from "../../lib/diff-streamer";
import SmoothStreamer from "../../lib/smooth-streamer";
import AiIndicatorWave from "../ai-indicator-wave";
@@ -103,6 +107,13 @@ export default class ModalDiffModal extends Component {
@action
updateResult(result) {
if (isAiCreditLimitError(result)) {
this.loading = false;
popupAiCreditLimitError(result);
this.cleanup();
return;
}
this.loading = false;
if (result.done) {
@@ -137,7 +148,11 @@ export default class ModalDiffModal extends Component {
this.progressChannel = result.progress_channel;
} catch (e) {
popupAjaxError(e);
if (isAiCreditLimitError(e)) {
popupAiCreditLimitError(e);
} else {
popupAjaxError(e);
}
}
}
@@ -8,6 +8,10 @@ import DModalCancel from "discourse/components/d-modal-cancel";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { i18n } from "discourse-i18n";
import {
isAiCreditLimitError,
popupAiCreditLimitError,
} from "../../lib/ai-errors";
import ThumbnailSuggestionItem from "../thumbnail-suggestion-item";
export default class ThumbnailSuggestions extends Component {
@@ -39,7 +43,11 @@ export default class ThumbnailSuggestions extends Component {
this.thumbnails = thumbnails.thumbnails;
} catch (error) {
popupAjaxError(error);
if (isAiCreditLimitError(error)) {
popupAiCreditLimitError(error);
} else {
popupAjaxError(error);
}
} finally {
this.loading = false;
}
@@ -2,6 +2,10 @@ import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { apiInitializer } from "discourse/lib/api";
import { i18n } from "discourse-i18n";
import {
isAiCreditLimitError,
popupAiCreditLimitError,
} from "../lib/ai-errors";
export default apiInitializer((api) => {
const buttonAttrs = {
@@ -72,7 +76,13 @@ export default apiInitializer((api) => {
imageCaptionPopup.updateCaption();
}
})
.catch(popupAjaxError)
.catch((error) => {
if (isAiCreditLimitError(error)) {
popupAiCreditLimitError(error);
} else {
popupAjaxError(error);
}
})
.finally(() => {
imageCaptionPopup.toggleLoadingState(false);
});
@@ -0,0 +1,71 @@
import { getOwnerWithFallback } from "discourse/lib/get-owner";
import { i18n } from "discourse-i18n";
/**
* Check if an error/payload is an AI credit limit error.
* Works with both AJAX errors and MessageBus payloads.
*
* @param {Object} errorOrPayload - AJAX error object or MessageBus payload
* @returns {boolean} - True if this is a credit limit error
*/
export function isAiCreditLimitError(errorOrPayload) {
if (!errorOrPayload) {
return false;
}
// AJAX error format:
if (errorOrPayload.jqXHR?.responseJSON?.error === "credit_limit_exceeded") {
return true;
}
// MessageBus payload format:
if (errorOrPayload.error_type === "credit_limit_exceeded") {
return true;
}
// Direct error object:
if (errorOrPayload.error === "credit_limit_exceeded") {
return true;
}
return false;
}
/**
* Format credit limit message with reset time.
*
* @param {Object} details - Details object containing reset time info
* @returns {string} - Formatted message
*/
function formatCreditLimitMessage(details) {
const resetTime =
details?.reset_time_absolute ||
details?.reset_time_relative ||
details?.reset_time;
if (resetTime && resetTime.length > 0) {
return i18n("discourse_ai.errors.credit_limit_dialog.message", {
reset_time: resetTime,
});
}
return i18n("discourse_ai.errors.credit_limit_dialog.message_without_time");
}
/**
* Show credit limit dialog to user.
* Similar to popupAjaxError but specialized for AI credit limits.
*
* @param {Object} errorOrPayload - AJAX error or MessageBus payload
*/
export function popupAiCreditLimitError(errorOrPayload) {
const dialog = getOwnerWithFallback(this).lookup("service:dialog");
const details =
errorOrPayload.jqXHR?.responseJSON?.details || errorOrPayload.details || {};
dialog.alert({
title: i18n("discourse_ai.errors.credit_limit_dialog.title"),
message: formatCreditLimitMessage(details),
});
}
@@ -0,0 +1,42 @@
.ai-llm-list__credit-allocation .fk-d-tooltip__trigger {
width: 100%;
display: block;
}
.ai-credit-bar {
--credit-bar-color: rgb(75, 192, 192, 0.8);
margin-top: 0.5em;
width: 100%;
&__progress {
height: 0.75rem;
background: var(--primary-low);
border-radius: var(--d-border-radius-large);
overflow: hidden;
margin-bottom: 0.25em;
width: 100%;
}
&__fill {
height: 100%;
background: var(--credit-bar-color);
transition: width 0.3s ease;
}
&__text {
font-size: var(--font-down-1);
color: var(--primary-high);
}
&--warning {
.ai-credit-bar__fill {
background: var(--danger);
}
}
}
.ai-credit-warning {
margin-top: 0.5em;
border-radius: var(--d-border-radius);
font-size: var(--font-down-1);
}
@@ -151,4 +151,29 @@
padding: 0.5em;
border-bottom: 1px solid var(--primary-low);
}
&__credit-allocations {
margin-top: 2em;
grid-column: span 2;
}
&__credit-model {
padding: 1em;
border-bottom: 1px solid var(--primary-low);
&:last-child {
border-bottom: none;
}
h4 {
margin: 0 0 0.5em 0;
font-size: var(--font-0);
}
}
&__credit-details {
margin-top: 0.5em;
font-size: var(--font-down-1);
color: var(--primary-medium);
}
}
@@ -433,6 +433,7 @@ en:
last_week: "Last week"
last_month: "Last month"
custom: "Custom..."
credit_allocations: "Credit allocations"
ai_persona:
ai_tools: "Tools"
@@ -648,6 +649,11 @@ en:
max_tokens_required: "Must be set if max usages is not set"
max_usages_help: "Maximum number of times each user in this group can use the AI model within the specified duration. This quota is tracked per individual user, not shared across the group."
max_usages_required: "Must be set if max tokens is not set"
credit_allocation:
credits_remaining: "%{remaining} of %{total} credits remaining (%{percentage}%)"
soft_limit_warning: "This model has %{percentage}% of AI credits remaining this month"
hard_limit_warning: "You have run out of AI credits for this model. AI features using this model will be unavailable until %{reset_date}"
next_reset: "Credits reset on %{time}"
usage:
ai_bot: "AI bot"
ai_helper: "Helper (%{persona})"
@@ -806,6 +812,12 @@ en:
save_caption: "Save"
no_content_error: "Add content first to perform AI actions on it"
errors:
credit_limit_dialog:
title: "AI credit limit reached"
message: "You've hit the AI credit limit for your plan. Responses will be unavailable until your limit resets at %{reset_time}."
message_without_time: "You've hit the AI credit limit for your plan. Responses will be unavailable until your limit resets."
reviewables:
model_used: "Model used:"
accuracy: "Accuracy:"
@@ -279,6 +279,8 @@ en:
ai_helper:
errors:
completion_request_failed: "Something went wrong while trying to provide suggestions. Please, try again."
credit_limit_exceeded_user: "You've hit the AI credit limit for your plan. Responses will be unavailable until your limit resets %{reset_time}."
credit_limit_exceeded_bot: "AI responses are currently unavailable due to credit limits. Please try again %{reset_time}."
prompts:
translate: Translate to %{language}
generate_titles: Suggest topic titles
@@ -649,6 +651,9 @@ en:
errors:
disabled: "The AI translation feature is not fully configured."
llm_credit_allocation:
limit_exceeded: "You have reached your AI credit limit. Please try again in %{reset_time}."
errors:
quota_exceeded: "You have exceeded the quota for this model. Please try again in %{relative_time}."
quota_required: "You must specify maximum tokens or usages for this model"
@@ -668,3 +673,5 @@ en:
dashboard:
problem:
ai_llm_status: "The LLM model: %{model_name} is encountering issues. Please check the <a href='%{url}'>model's configuration page</a>."
ai_credit_soft_limit: "%{model_name} has only %{percentage_remaining}% of credits remaining. Credits will reset at %{reset_date}. <a href='%{url}'>View AI models</a>"
ai_credit_hard_limit: "You have run out of AI credits for %{model_name}. AI features will be unavailable until %{reset_date}. <a href='%{url}'>View AI models</a>"
@@ -0,0 +1,26 @@
# frozen_string_literal: true
class AddLlmCreditAllocationSystem < ActiveRecord::Migration[7.2]
def change
create_table :llm_credit_allocations do |t|
t.bigint :llm_model_id, null: false
t.bigint :monthly_credits, null: false
t.bigint :monthly_used, null: false, default: 0
t.datetime :last_reset_at, null: false
t.integer :soft_limit_percentage, null: false, default: 80
t.timestamps
end
add_index :llm_credit_allocations, :llm_model_id, unique: true
create_table :llm_feature_credit_costs do |t|
t.bigint :llm_model_id, null: false
t.string :feature_name, null: false
t.decimal :credits_per_token, precision: 10, scale: 4, null: false, default: 1.0
t.timestamps
end
add_index :llm_feature_credit_costs, :llm_model_id
add_index :llm_feature_credit_costs, %i[llm_model_id feature_name], unique: true
end
end
@@ -21,6 +21,9 @@ module DiscourseAi
base64_to_image(artifacts, user.id)
elsif model == "dall_e_3"
llm_model = find_llm_model_for_feature("illustrate_post")
LlmCreditAllocation.check_credits!(llm_model) if llm_model
attribution =
I18n.t(
"discourse_ai.ai_helper.painter.attribution.#{SiteSetting.ai_helper_illustrate_post_model}",
@@ -38,6 +41,16 @@ module DiscourseAi
private
def find_llm_model_for_feature(feature_name)
persona_id = SiteSetting.ai_helper_post_illustrator_persona
return nil if persona_id.blank?
persona = AiPersona.find_by(id: persona_id)
return nil if persona.blank?
LlmModel.find_by(id: persona.default_llm_id)
end
def base64_to_image(artifacts, user_id)
attribution =
I18n.t(
@@ -78,6 +78,8 @@ module DiscourseAi
&blk
)
LlmQuota.check_quotas!(@llm_model, user)
LlmCreditAllocation.check_credits!(@llm_model)
start_time = Time.now
if cancel_manager && cancel_manager.cancelled?
@@ -279,6 +281,13 @@ module DiscourseAi
log.duration_msecs = (Time.now - start_time) * 1000
log.save!
LlmQuota.log_usage(@llm_model, user, log.request_tokens, log.response_tokens)
LlmCreditAllocation.deduct_credits!(
@llm_model,
feature_name,
log.request_tokens,
log.response_tokens,
)
if Rails.env.development? && ENV["DISCOURSE_AI_DEBUG"]
puts "#{self.class.name}: request_tokens #{log.request_tokens} response_tokens #{log.response_tokens}"
end
+4
View File
@@ -48,6 +48,7 @@ register_asset "stylesheets/modules/embeddings/common/ai-embedding-editor.scss"
register_asset "stylesheets/modules/llms/common/usage.scss"
register_asset "stylesheets/modules/llms/common/spam.scss"
register_asset "stylesheets/modules/llms/common/ai-llm-quotas.scss"
register_asset "stylesheets/modules/llms/common/ai-credit-bar.scss"
register_asset "stylesheets/modules/ai-bot/common/ai-tools.scss"
@@ -98,6 +99,8 @@ after_initialize do
].each { |a_module| a_module.inject_into(self) }
register_problem_check ProblemCheck::AiLlmStatus
register_problem_check ProblemCheck::AiCreditSoftLimit
register_problem_check ProblemCheck::AiCreditHardLimit
register_reviewable_type ReviewableAiChatMessage
register_reviewable_type ReviewableAiPost
@@ -148,6 +151,7 @@ after_initialize do
face-smile
face-meh
face-angry
circle-info
]
plugin_icons.each { |icon| register_svg_icon(icon) }
@@ -0,0 +1,9 @@
# frozen_string_literal: true
Fabricator(:llm_credit_allocation) do
llm_model
monthly_credits 1_000_000
monthly_used 0
last_reset_at { Time.current }
soft_limit_percentage 80
end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:llm_feature_credit_cost) do
llm_model
feature_name "ai_helper"
credits_per_token 1.0
end
@@ -161,4 +161,43 @@ RSpec.describe Jobs::StreamPostHelper do
end
end
end
describe "#publish_error" do
fab!(:seeded_model)
fab!(:allocation) { Fabricate(:llm_credit_allocation, llm_model: seeded_model) }
fab!(:user)
it "publishes error details with reset times to MessageBus" do
exception = LlmCreditAllocation::CreditLimitExceeded.new("Test error", allocation: allocation)
channel = "/test/channel"
messages =
MessageBus.track_publish(channel) { job.send(:publish_error, channel, user, exception) }
expect(messages.count).to eq(1)
message_data = messages.first.data
expect(message_data[:error]).to eq(true)
expect(message_data[:error_type]).to eq("credit_limit_exceeded")
expect(message_data[:message]).to eq("Test error")
expect(message_data[:done]).to eq(true)
expect(message_data[:details][:reset_time_absolute]).to be_present
expect(message_data[:details][:reset_time_relative]).to be_present
end
it "handles exception without allocation gracefully" do
exception = LlmCreditAllocation::CreditLimitExceeded.new("Test error")
channel = "/test/channel"
messages =
MessageBus.track_publish(channel) { job.send(:publish_error, channel, user, exception) }
expect(messages.count).to eq(1)
message_data = messages.first.data
expect(message_data[:error]).to eq(true)
expect(message_data[:message]).to eq("Test error")
expect(message_data[:details]).to eq({})
end
end
end
@@ -0,0 +1,405 @@
# frozen_string_literal: true
RSpec.describe LlmCreditAllocation do
fab!(:seeded_model)
fab!(:llm_model)
describe "validations" do
it "requires llm_model_id" do
allocation = LlmCreditAllocation.new(monthly_credits: 1000)
expect(allocation).not_to be_valid
expect(allocation.errors[:llm_model_id]).to be_present
end
it "requires unique llm_model_id" do
Fabricate(:llm_credit_allocation, llm_model: llm_model)
allocation = LlmCreditAllocation.new(llm_model: llm_model, monthly_credits: 1000)
expect(allocation).not_to be_valid
expect(allocation.errors[:llm_model_id]).to be_present
end
it "requires monthly_credits to be positive" do
allocation = LlmCreditAllocation.new(llm_model: llm_model, monthly_credits: 0)
expect(allocation).not_to be_valid
expect(allocation.errors[:monthly_credits]).to be_present
end
it "requires soft_limit_percentage between 0 and 100" do
allocation = LlmCreditAllocation.new(llm_model: llm_model, monthly_credits: 1000)
allocation.soft_limit_percentage = 101
expect(allocation).not_to be_valid
allocation.soft_limit_percentage = -1
expect(allocation).not_to be_valid
allocation.soft_limit_percentage = 80
expect(allocation).to be_valid
end
it "sets last_reset_at on create" do
allocation = Fabricate.build(:llm_credit_allocation, llm_model: llm_model)
allocation.last_reset_at = nil
allocation.save!
expect(allocation.last_reset_at).to be_present
end
end
describe "#credits_remaining" do
it "returns remaining credits" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 300)
expect(allocation.credits_remaining).to eq(700)
end
it "returns 0 when credits are exhausted" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1200)
expect(allocation.credits_remaining).to eq(0)
end
end
describe "#percentage_used" do
it "calculates percentage correctly" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 250)
expect(allocation.percentage_used).to eq(25.0)
end
it "caps at 100%" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1500)
expect(allocation.percentage_used).to eq(100.0)
end
it "returns 0 when monthly_credits is 0" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 0)
allocation.monthly_credits = 0
expect(allocation.percentage_used).to eq(0)
end
end
describe "#percentage_remaining" do
it "calculates percentage correctly" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 250)
expect(allocation.percentage_remaining).to eq(75.0)
end
it "floors at 0%" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1500)
expect(allocation.percentage_remaining).to eq(0.0)
end
it "returns 100.0 when monthly_credits is 0" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 0)
allocation.monthly_credits = 0
expect(allocation.percentage_remaining).to eq(100.0)
end
it "returns 100.0 when no credits used" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 0)
expect(allocation.percentage_remaining).to eq(100.0)
end
end
describe "#soft_limit_remaining_reached?" do
it "returns true when percentage remaining equals (100 - soft_limit)" do
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 800,
soft_limit_percentage: 80,
)
expect(allocation.soft_limit_remaining_reached?).to be true
end
it "returns true when percentage remaining is below (100 - soft_limit)" do
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 900,
soft_limit_percentage: 80,
)
expect(allocation.soft_limit_remaining_reached?).to be true
end
it "returns false when percentage remaining is above (100 - soft_limit)" do
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 700,
soft_limit_percentage: 80,
)
expect(allocation.soft_limit_remaining_reached?).to be false
end
end
describe "#hard_limit_remaining_reached?" do
it "returns true when credits_remaining is 0" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1000)
expect(allocation.hard_limit_remaining_reached?).to be true
end
it "returns true when credits_remaining is negative" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1200)
expect(allocation.hard_limit_remaining_reached?).to be true
end
it "returns false when credits_remaining is positive" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 999)
expect(allocation.hard_limit_remaining_reached?).to be false
end
end
describe "#credits_available?" do
it "returns true when credits are available" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 500)
expect(allocation.credits_available?).to be true
end
it "returns false when hard limit is reached" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1000)
expect(allocation.credits_available?).to be false
end
it "returns false when hard limit is exceeded" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1100)
expect(allocation.credits_available?).to be false
end
end
describe "#soft_limit_reached?" do
it "returns true when percentage equals soft limit" do
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 800,
soft_limit_percentage: 80,
)
expect(allocation.soft_limit_reached?).to be true
end
it "returns true when percentage exceeds soft limit" do
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 900,
soft_limit_percentage: 80,
)
expect(allocation.soft_limit_reached?).to be true
end
it "returns false when below soft limit" do
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 700,
soft_limit_percentage: 80,
)
expect(allocation.soft_limit_reached?).to be false
end
end
describe "#hard_limit_reached?" do
it "returns true when monthly_used equals monthly_credits" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1000)
expect(allocation.hard_limit_reached?).to be true
end
it "returns true when monthly_used exceeds monthly_credits" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1200)
expect(allocation.hard_limit_reached?).to be true
end
it "returns false when below limit" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 999)
expect(allocation.hard_limit_reached?).to be false
end
end
describe "#next_reset_at" do
it "returns one month after last_reset_at" do
freeze_time
allocation = Fabricate(:llm_credit_allocation, last_reset_at: Time.current)
expect(allocation.next_reset_at).to eq_time(1.month.from_now)
end
end
describe "#should_reset?" do
it "returns true when time has passed next_reset_at" do
allocation = Fabricate(:llm_credit_allocation, last_reset_at: 2.months.ago)
expect(allocation.should_reset?).to be true
end
it "returns false when before next_reset_at" do
allocation = Fabricate(:llm_credit_allocation, last_reset_at: 1.day.ago)
expect(allocation.should_reset?).to be false
end
end
describe "#reset_if_needed!" do
it "resets credits when time has passed" do
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 800,
last_reset_at: 2.months.ago,
)
freeze_time do
allocation.reset_if_needed!
allocation.reload
expect(allocation.monthly_used).to eq(0)
expect(allocation.last_reset_at).to be_within(1.second).of(Time.current)
end
end
it "does not reset when time has not passed" do
original_time = 1.day.ago
allocation =
Fabricate(
:llm_credit_allocation,
monthly_credits: 1000,
monthly_used: 800,
last_reset_at: original_time,
)
allocation.reset_if_needed!
allocation.reload
expect(allocation.monthly_used).to eq(800)
expect(allocation.last_reset_at).to be_within(1.second).of(original_time)
end
end
describe "#deduct_credits!" do
it "increments monthly_used" do
allocation = Fabricate(:llm_credit_allocation, monthly_used: 100)
allocation.deduct_credits!(50)
allocation.reload
expect(allocation.monthly_used).to eq(150)
end
end
describe "#check_credits!" do
it "raises error when hard limit reached" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1000)
expect { allocation.check_credits! }.to raise_error(LlmCreditAllocation::CreditLimitExceeded)
end
it "does not raise error when below limit" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 500)
expect { allocation.check_credits! }.not_to raise_error
end
it "attaches allocation to raised exception" do
allocation = Fabricate(:llm_credit_allocation, monthly_credits: 1000, monthly_used: 1000)
begin
allocation.check_credits!
fail "Expected exception to be raised"
rescue LlmCreditAllocation::CreditLimitExceeded => e
expect(e.allocation).to eq(allocation)
end
end
end
describe ".credits_available?" do
fab!(:llm_model) { Fabricate(:llm_model, id: -1) }
it "returns true when model has no credit system" do
regular_model = Fabricate(:llm_model)
expect(LlmCreditAllocation.credits_available?(regular_model)).to be true
end
it "returns true when model is nil" do
expect(LlmCreditAllocation.credits_available?(nil)).to be true
end
it "returns true when model has no allocation" do
expect(LlmCreditAllocation.credits_available?(llm_model)).to be true
end
it "returns true when credits are available" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 500,
)
expect(LlmCreditAllocation.credits_available?(llm_model)).to be true
end
it "returns false when hard limit reached" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 1000,
)
expect(LlmCreditAllocation.credits_available?(llm_model)).to be false
end
it "resets and returns true when reset is needed" do
allocation =
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 1000,
last_reset_at: 2.months.ago,
)
freeze_time do
result = LlmCreditAllocation.credits_available?(llm_model)
allocation.reload
expect(result).to be true
expect(allocation.monthly_used).to eq(0)
end
end
end
describe "#formatted_reset_time" do
it "returns formatted reset time" do
freeze_time do
allocation = Fabricate(:llm_credit_allocation, last_reset_at: Time.current)
formatted = allocation.formatted_reset_time
expect(formatted).to match(/\d{1,2}:\d{2}[ap]m on \w+ \d{1,2}, \d{4}/)
end
end
it "returns empty string when next_reset_at is nil" do
allocation = Fabricate(:llm_credit_allocation)
allocation.stubs(:next_reset_at).returns(nil)
expect(allocation.formatted_reset_time).to eq("")
end
end
describe "#relative_reset_time" do
it "returns relative time until reset" do
freeze_time do
allocation = Fabricate(:llm_credit_allocation, last_reset_at: Time.current)
relative = allocation.relative_reset_time
expect(relative).to match(/in .+/)
end
end
it "returns empty string when next_reset_at is nil" do
allocation = Fabricate(:llm_credit_allocation)
allocation.stubs(:next_reset_at).returns(nil)
expect(allocation.relative_reset_time).to eq("")
end
end
end
@@ -0,0 +1,132 @@
# frozen_string_literal: true
RSpec.describe LlmFeatureCreditCost do
fab!(:llm_model)
describe "validations" do
it "requires llm_model_id" do
cost = LlmFeatureCreditCost.new(feature_name: "test", credits_per_token: 1.0)
expect(cost).not_to be_valid
expect(cost.errors[:llm_model_id]).to be_present
end
it "requires feature_name" do
cost = LlmFeatureCreditCost.new(llm_model: llm_model, credits_per_token: 1.0)
expect(cost).not_to be_valid
expect(cost.errors[:feature_name]).to be_present
end
it "requires unique feature_name per llm_model" do
Fabricate(:llm_feature_credit_cost, llm_model: llm_model, feature_name: "test")
cost =
LlmFeatureCreditCost.new(llm_model: llm_model, feature_name: "test", credits_per_token: 2.0)
expect(cost).not_to be_valid
expect(cost.errors[:feature_name]).to be_present
end
it "requires credits_per_token to be non-negative" do
cost =
LlmFeatureCreditCost.new(
llm_model: llm_model,
feature_name: "test",
credits_per_token: -1.0,
)
expect(cost).not_to be_valid
expect(cost.errors[:credits_per_token]).to be_present
end
it "allows credits_per_token to be 0" do
cost =
LlmFeatureCreditCost.new(
llm_model: llm_model,
feature_name: "spam_detection",
credits_per_token: 0.0,
)
expect(cost).to be_valid
end
end
describe ".credit_cost_for" do
it "returns specific cost when feature exists" do
Fabricate(
:llm_feature_credit_cost,
llm_model: llm_model,
feature_name: "ai_helper",
credits_per_token: 2.5,
)
expect(LlmFeatureCreditCost.credit_cost_for(llm_model, "ai_helper")).to eq(2.5)
end
it "returns default cost when feature not found but default exists" do
Fabricate(
:llm_feature_credit_cost,
llm_model: llm_model,
feature_name: "default",
credits_per_token: 1.5,
)
expect(LlmFeatureCreditCost.credit_cost_for(llm_model, "unknown_feature")).to eq(1.5)
end
it "returns 1.0 when neither feature nor default exists" do
expect(LlmFeatureCreditCost.credit_cost_for(llm_model, "unknown_feature")).to eq(1.0)
end
it "returns 1.0 when llm_model is nil" do
expect(LlmFeatureCreditCost.credit_cost_for(nil, "ai_helper")).to eq(1.0)
end
it "returns 1.0 when feature_name is blank" do
expect(LlmFeatureCreditCost.credit_cost_for(llm_model, nil)).to eq(1.0)
expect(LlmFeatureCreditCost.credit_cost_for(llm_model, "")).to eq(1.0)
end
end
describe ".calculate_credit_cost" do
it "calculates cost correctly" do
Fabricate(
:llm_feature_credit_cost,
llm_model: llm_model,
feature_name: "ai_helper",
credits_per_token: 2.0,
)
expect(LlmFeatureCreditCost.calculate_credit_cost(llm_model, "ai_helper", 100)).to eq(200)
end
it "rounds up to nearest integer" do
Fabricate(
:llm_feature_credit_cost,
llm_model: llm_model,
feature_name: "ai_helper",
credits_per_token: 1.5,
)
expect(LlmFeatureCreditCost.calculate_credit_cost(llm_model, "ai_helper", 100)).to eq(150)
expect(LlmFeatureCreditCost.calculate_credit_cost(llm_model, "ai_helper", 101)).to eq(152)
end
it "handles fractional credits_per_token" do
Fabricate(
:llm_feature_credit_cost,
llm_model: llm_model,
feature_name: "ai_helper",
credits_per_token: 0.5,
)
expect(LlmFeatureCreditCost.calculate_credit_cost(llm_model, "ai_helper", 100)).to eq(50)
end
it "returns 0 for spam_detection with 0 cost" do
Fabricate(
:llm_feature_credit_cost,
llm_model: llm_model,
feature_name: "spam_detection",
credits_per_token: 0.0,
)
expect(LlmFeatureCreditCost.calculate_credit_cost(llm_model, "spam_detection", 100)).to eq(0)
end
end
end
@@ -12,4 +12,22 @@ RSpec.describe LlmModel do
expect(llm_model.api_key).to eq("blabla")
end
end
describe "#credit_system_enabled?" do
fab!(:seeded_model)
fab!(:regular_model, :llm_model)
it "returns false for non-seeded models" do
expect(regular_model.credit_system_enabled?).to be false
end
it "returns false for seeded models without credit allocation" do
expect(seeded_model.credit_system_enabled?).to be false
end
it "returns true for seeded models with credit allocation" do
Fabricate(:llm_credit_allocation, llm_model: seeded_model)
expect(seeded_model.credit_system_enabled?).to be true
end
end
end
@@ -0,0 +1,107 @@
# frozen_string_literal: true
RSpec.describe ProblemCheck::AiCreditHardLimit do
fab!(:llm_model) { Fabricate(:llm_model, id: -1) }
before { SiteSetting.discourse_ai_enabled = true }
describe "#call" do
it "returns no problems when no credit allocations exist" do
problems = described_class.new.call
expect(problems).to be_empty
end
it "returns no problems when credits are not exhausted" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 850,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems).to be_empty
end
it "returns hard limit problem when credits are exhausted" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 1000,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems.size).to eq(1)
expect(problems.first.identifier).to eq("ai_credit_hard_limit")
expect(problems.first.priority).to eq("high")
expect(problems.first.target).to eq(llm_model.id)
end
it "returns hard limit problem when credits are over-exhausted" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 1200,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems.size).to eq(1)
expect(problems.first.identifier).to eq("ai_credit_hard_limit")
end
it "resets credits before checking if needed" do
allocation =
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 1000,
last_reset_at: 2.months.ago,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems).to be_empty
allocation.reload
expect(allocation.monthly_used).to eq(0)
end
it "skips non-seeded models" do
non_seeded = Fabricate(:llm_model, id: 1)
Fabricate(
:llm_credit_allocation,
llm_model: non_seeded,
monthly_credits: 1000,
monthly_used: 1000,
)
problems = described_class.new.call
expect(problems).to be_empty
end
it "returns no problems when discourse_ai is disabled" do
SiteSetting.discourse_ai_enabled = false
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 1000,
)
problems = described_class.new.call
expect(problems).to be_empty
end
end
end
@@ -0,0 +1,108 @@
# frozen_string_literal: true
RSpec.describe ProblemCheck::AiCreditSoftLimit do
fab!(:llm_model) { Fabricate(:llm_model, id: -1) }
before { SiteSetting.discourse_ai_enabled = true }
describe "#call" do
it "returns no problems when no credit allocations exist" do
problems = described_class.new.call
expect(problems).to be_empty
end
it "returns no problems when credits are not at soft limit" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 700,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems).to be_empty
end
it "returns soft limit problem when soft limit is reached" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 850,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems.size).to eq(1)
expect(problems.first.identifier).to eq("ai_credit_soft_limit")
expect(problems.first.priority).to eq("low")
expect(problems.first.target).to eq(llm_model.id)
end
it "does not return soft limit problem when hard limit is reached" do
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 1000,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems).to be_empty
end
it "resets credits before checking if needed" do
allocation =
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 850,
last_reset_at: 2.months.ago,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems).to be_empty
allocation.reload
expect(allocation.monthly_used).to eq(0)
end
it "skips non-seeded models" do
non_seeded = Fabricate(:llm_model, id: 1)
Fabricate(
:llm_credit_allocation,
llm_model: non_seeded,
monthly_credits: 1000,
monthly_used: 850,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems).to be_empty
end
it "returns no problems when discourse_ai is disabled" do
SiteSetting.discourse_ai_enabled = false
Fabricate(
:llm_credit_allocation,
llm_model: llm_model,
monthly_credits: 1000,
monthly_used: 850,
soft_limit_percentage: 80,
)
problems = described_class.new.call
expect(problems).to be_empty
end
end
end
@@ -0,0 +1,162 @@
import { getOwner } from "@ember/owner";
import { setupTest } from "ember-qunit";
import { module, test } from "qunit";
import sinon from "sinon";
import {
isAiCreditLimitError,
popupAiCreditLimitError,
} from "discourse/plugins/discourse-ai/discourse/lib/ai-errors";
module("Unit | Utility | ai-errors", function (hooks) {
setupTest(hooks);
module("isAiCreditLimitError", function () {
test("detects AJAX error format from controller", function (assert) {
const error = {
jqXHR: {
responseJSON: {
error: "credit_limit_exceeded",
},
},
};
assert.true(
isAiCreditLimitError(error),
"Should detect controller error format"
);
});
test("detects MessageBus payload format from streaming job", function (assert) {
const payload = {
error_type: "credit_limit_exceeded",
message: "Credit limit exceeded",
details: {},
};
assert.true(
isAiCreditLimitError(payload),
"Should detect streaming job format"
);
});
test("detects direct error object format", function (assert) {
const error = {
error: "credit_limit_exceeded",
};
assert.true(
isAiCreditLimitError(error),
"Should detect direct error format"
);
});
test("returns false for non-credit-limit errors", function (assert) {
const error = {
jqXHR: {
responseJSON: {
error: "some_other_error",
},
},
};
assert.false(
isAiCreditLimitError(error),
"Should not detect other errors"
);
});
test("returns false for unrelated objects", function (assert) {
assert.false(
isAiCreditLimitError({}),
"Should return false for empty object"
);
assert.false(isAiCreditLimitError(null), "Should return false for null");
assert.false(
isAiCreditLimitError(undefined),
"Should return false for undefined"
);
});
});
module("popupAiCreditLimitError", function () {
test("shows dialog with reset time when available", function (assert) {
const dialogService = getOwner(this).lookup("service:dialog");
const alertStub = sinon.stub(dialogService, "alert");
const error = {
jqXHR: {
responseJSON: {
error: "credit_limit_exceeded",
details: {
reset_time_absolute: "5:40pm on Dec 25, 2024",
},
},
},
};
popupAiCreditLimitError(error);
assert.true(alertStub.calledOnce, "Dialog should be shown");
const callArgs = alertStub.firstCall.args[0];
assert.true(
callArgs.message.includes("5:40pm on Dec 25, 2024"),
"Message should include reset time"
);
assert.strictEqual(
callArgs.title,
"AI credit limit reached",
"Title should be correct"
);
alertStub.restore();
});
test("shows dialog without reset time when unavailable", function (assert) {
const dialogService = getOwner(this).lookup("service:dialog");
const alertStub = sinon.stub(dialogService, "alert");
const error = {
error_type: "credit_limit_exceeded",
details: {},
};
popupAiCreditLimitError(error);
assert.true(alertStub.calledOnce, "Dialog should be shown");
const callArgs = alertStub.firstCall.args[0];
assert.false(
callArgs.message.includes("at"),
"Message should not include 'at' for time"
);
assert.true(
callArgs.message.includes("until your limit resets"),
"Message should mention reset"
);
alertStub.restore();
});
test("handles MessageBus payload format", function (assert) {
const dialogService = getOwner(this).lookup("service:dialog");
const alertStub = sinon.stub(dialogService, "alert");
const payload = {
error_type: "credit_limit_exceeded",
details: {
reset_time_relative: "in 2 hours",
},
};
popupAiCreditLimitError(payload);
assert.true(alertStub.calledOnce, "Dialog should be shown");
const callArgs = alertStub.firstCall.args[0];
assert.true(
callArgs.message.includes("in 2 hours"),
"Message should include relative time"
);
alertStub.restore();
});
});
});