mirror of
https://github.com/discourse/discourse.git
synced 2026-08-05 02:43:08 -05:00
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:
@@ -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
|
||||
|
||||
+2
@@ -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">
|
||||
|
||||
+23
-2
@@ -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>
|
||||
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user