2024-05-13 12:46:42 -03:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
module DiscourseAi
|
|
|
|
module Admin
|
|
|
|
class AiLlmsController < ::Admin::AdminController
|
|
|
|
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
|
|
|
|
|
|
|
def index
|
2025-01-14 15:54:09 +11:00
|
|
|
llms = LlmModel.all.includes(:llm_quotas).order(:display_name)
|
2024-05-13 12:46:42 -03:00
|
|
|
|
|
|
|
render json: {
|
|
|
|
ai_llms:
|
|
|
|
ActiveModel::ArraySerializer.new(
|
|
|
|
llms,
|
|
|
|
each_serializer: LlmModelSerializer,
|
|
|
|
root: false,
|
2024-10-22 11:16:02 +11:00
|
|
|
scope: {
|
|
|
|
llm_usage: DiscourseAi::Configuration::LlmEnumerator.global_usage,
|
|
|
|
},
|
2024-05-13 12:46:42 -03:00
|
|
|
).as_json,
|
|
|
|
meta: {
|
2024-06-24 19:26:30 -03:00
|
|
|
provider_params: LlmModel.provider_params,
|
2024-06-21 17:32:15 +10:00
|
|
|
presets: DiscourseAi::Completions::Llm.presets,
|
2024-05-13 12:46:42 -03:00
|
|
|
providers: DiscourseAi::Completions::Llm.provider_names,
|
|
|
|
tokenizers:
|
|
|
|
DiscourseAi::Completions::Llm.tokenizer_names.map { |tn|
|
|
|
|
{ id: tn, name: tn.split("::").last }
|
|
|
|
},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
2024-12-12 10:48:58 +11:00
|
|
|
def new
|
|
|
|
end
|
|
|
|
|
|
|
|
def edit
|
2024-05-13 12:46:42 -03:00
|
|
|
llm_model = LlmModel.find(params[:id])
|
|
|
|
render json: LlmModelSerializer.new(llm_model)
|
|
|
|
end
|
|
|
|
|
|
|
|
def create
|
2024-05-13 15:54:42 -03:00
|
|
|
llm_model = LlmModel.new(ai_llm_params)
|
2025-01-14 15:54:09 +11:00
|
|
|
|
|
|
|
# we could do nested attributes but the mechanics are not ideal leading
|
|
|
|
# to lots of complex debugging, this is simpler
|
|
|
|
quota_params.each { |quota| llm_model.llm_quotas.build(quota) } if quota_params
|
|
|
|
|
2024-05-13 15:54:42 -03:00
|
|
|
if llm_model.save
|
2024-06-25 12:45:19 -03:00
|
|
|
llm_model.toggle_companion_user
|
2025-06-12 12:39:58 -07:00
|
|
|
log_llm_model_creation(llm_model)
|
2024-09-30 03:15:11 -04:00
|
|
|
render json: LlmModelSerializer.new(llm_model), status: :created
|
2024-05-13 12:46:42 -03:00
|
|
|
else
|
2024-07-31 17:53:18 +10:00
|
|
|
render_json_error llm_model
|
2024-05-13 12:46:42 -03:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def update
|
|
|
|
llm_model = LlmModel.find(params[:id])
|
|
|
|
|
2025-06-12 12:39:58 -07:00
|
|
|
# Capture initial state for logging
|
|
|
|
initial_attributes = llm_model.attributes.dup
|
|
|
|
initial_quotas = llm_model.llm_quotas.map(&:attributes)
|
|
|
|
|
2025-01-14 15:54:09 +11:00
|
|
|
if params[:ai_llm].key?(:llm_quotas)
|
|
|
|
if quota_params
|
|
|
|
existing_quota_group_ids = llm_model.llm_quotas.pluck(:group_id)
|
|
|
|
new_quota_group_ids = quota_params.map { |q| q[:group_id] }
|
|
|
|
|
|
|
|
llm_model
|
|
|
|
.llm_quotas
|
|
|
|
.where(group_id: existing_quota_group_ids - new_quota_group_ids)
|
|
|
|
.destroy_all
|
|
|
|
|
|
|
|
quota_params.each do |quota_param|
|
|
|
|
quota = llm_model.llm_quotas.find_or_initialize_by(group_id: quota_param[:group_id])
|
|
|
|
quota.update!(quota_param)
|
|
|
|
end
|
|
|
|
else
|
|
|
|
llm_model.llm_quotas.destroy_all
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-08-28 15:57:58 -03:00
|
|
|
if llm_model.seeded?
|
|
|
|
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
|
|
|
|
end
|
|
|
|
|
2024-06-24 19:26:30 -03:00
|
|
|
if llm_model.update(ai_llm_params(updating: llm_model))
|
2024-06-18 14:32:14 -03:00
|
|
|
llm_model.toggle_companion_user
|
2025-06-12 12:39:58 -07:00
|
|
|
log_llm_model_update(llm_model, initial_attributes, initial_quotas)
|
2024-06-27 10:43:00 -03:00
|
|
|
render json: LlmModelSerializer.new(llm_model)
|
2024-05-13 12:46:42 -03:00
|
|
|
else
|
2024-07-31 17:53:18 +10:00
|
|
|
render_json_error llm_model
|
2024-05-13 12:46:42 -03:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-05-27 16:44:08 -03:00
|
|
|
def destroy
|
|
|
|
llm_model = LlmModel.find(params[:id])
|
|
|
|
|
2024-08-28 15:57:58 -03:00
|
|
|
if llm_model.seeded?
|
|
|
|
return render_json_error(I18n.t("discourse_ai.llm.cannot_delete_builtin"), status: 403)
|
|
|
|
end
|
|
|
|
|
2024-06-19 18:01:35 -03:00
|
|
|
in_use_by = DiscourseAi::Configuration::LlmValidator.new.modules_using(llm_model)
|
2024-05-27 16:44:08 -03:00
|
|
|
|
|
|
|
if !in_use_by.empty?
|
|
|
|
return(
|
|
|
|
render_json_error(
|
|
|
|
I18n.t(
|
|
|
|
"discourse_ai.llm.delete_failed",
|
|
|
|
settings: in_use_by.join(", "),
|
|
|
|
count: in_use_by.length,
|
|
|
|
),
|
|
|
|
status: 409,
|
|
|
|
)
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
2025-06-12 12:39:58 -07:00
|
|
|
# Capture model details for logging before destruction
|
|
|
|
model_details = {
|
|
|
|
model_id: llm_model.id,
|
|
|
|
display_name: llm_model.display_name,
|
|
|
|
name: llm_model.name,
|
|
|
|
provider: llm_model.provider,
|
|
|
|
}
|
|
|
|
|
2024-06-25 12:45:19 -03:00
|
|
|
# Clean up companion users
|
|
|
|
llm_model.enabled_chat_bot = false
|
|
|
|
llm_model.toggle_companion_user
|
|
|
|
|
2024-05-27 16:44:08 -03:00
|
|
|
if llm_model.destroy
|
2025-06-12 12:39:58 -07:00
|
|
|
log_llm_model_deletion(model_details)
|
2024-05-27 16:44:08 -03:00
|
|
|
head :no_content
|
|
|
|
else
|
|
|
|
render_json_error llm_model
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-05-21 13:35:50 -03:00
|
|
|
def test
|
|
|
|
RateLimiter.new(current_user, "llm_test_#{current_user.id}", 3, 1.minute).performed!
|
|
|
|
|
|
|
|
llm_model = LlmModel.new(ai_llm_params)
|
|
|
|
|
2024-06-19 18:01:35 -03:00
|
|
|
DiscourseAi::Configuration::LlmValidator.new.run_test(llm_model)
|
2024-05-21 13:35:50 -03:00
|
|
|
|
|
|
|
render json: { success: true }
|
|
|
|
rescue DiscourseAi::Completions::Endpoints::Base::CompletionFailed => e
|
|
|
|
render json: { success: false, error: e.message }
|
|
|
|
end
|
|
|
|
|
2024-05-13 12:46:42 -03:00
|
|
|
private
|
|
|
|
|
2025-01-14 15:54:09 +11:00
|
|
|
def quota_params
|
|
|
|
if params[:ai_llm][:llm_quotas].present?
|
|
|
|
params[:ai_llm][:llm_quotas].map do |quota|
|
|
|
|
mapped = {}
|
|
|
|
mapped[:group_id] = quota[:group_id].to_i
|
|
|
|
mapped[:max_tokens] = quota[:max_tokens].to_i if quota[:max_tokens].present?
|
|
|
|
mapped[:max_usages] = quota[:max_usages].to_i if quota[:max_usages].present?
|
|
|
|
mapped[:duration_seconds] = quota[:duration_seconds].to_i
|
|
|
|
mapped
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2024-06-24 19:26:30 -03:00
|
|
|
def ai_llm_params(updating: nil)
|
2024-08-06 14:35:35 -03:00
|
|
|
return {} if params[:ai_llm].blank?
|
|
|
|
|
2024-06-24 19:26:30 -03:00
|
|
|
permitted =
|
|
|
|
params.require(:ai_llm).permit(
|
|
|
|
:display_name,
|
|
|
|
:name,
|
|
|
|
:provider,
|
|
|
|
:tokenizer,
|
|
|
|
:max_prompt_tokens,
|
2025-04-17 14:44:15 -07:00
|
|
|
:max_output_tokens,
|
2024-06-24 19:26:30 -03:00
|
|
|
:api_key,
|
|
|
|
:enabled_chat_bot,
|
2024-07-24 16:29:47 -03:00
|
|
|
:vision_enabled,
|
2025-04-17 15:09:48 -07:00
|
|
|
:input_cost,
|
|
|
|
:cached_input_cost,
|
|
|
|
:output_cost,
|
2024-06-24 19:26:30 -03:00
|
|
|
)
|
|
|
|
|
|
|
|
provider = updating ? updating.provider : permitted[:provider]
|
2024-07-30 13:44:57 -03:00
|
|
|
permit_url = provider != LlmModel::BEDROCK_PROVIDER_NAME
|
2024-06-24 19:26:30 -03:00
|
|
|
|
2024-08-06 14:35:35 -03:00
|
|
|
new_url = params.dig(:ai_llm, :url)
|
|
|
|
permitted[:url] = new_url if permit_url && new_url
|
2024-06-24 19:26:30 -03:00
|
|
|
|
2024-08-21 11:41:55 -03:00
|
|
|
extra_field_names = LlmModel.provider_params.dig(provider&.to_sym)
|
|
|
|
if extra_field_names.present?
|
|
|
|
received_prov_params =
|
|
|
|
params.dig(:ai_llm, :provider_params)&.slice(*extra_field_names.keys)
|
|
|
|
|
|
|
|
if received_prov_params.present?
|
|
|
|
received_prov_params.each do |pname, value|
|
|
|
|
if extra_field_names[pname.to_sym] == :checkbox
|
|
|
|
received_prov_params[pname] = ActiveModel::Type::Boolean.new.cast(value)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
permitted[:provider_params] = received_prov_params.permit!
|
|
|
|
end
|
|
|
|
end
|
2024-06-24 19:26:30 -03:00
|
|
|
|
|
|
|
permitted
|
2024-05-13 12:46:42 -03:00
|
|
|
end
|
2025-06-12 12:39:58 -07:00
|
|
|
|
|
|
|
def ai_llm_logger_fields
|
|
|
|
{
|
|
|
|
display_name: {
|
|
|
|
},
|
|
|
|
name: {
|
|
|
|
},
|
|
|
|
provider: {
|
|
|
|
},
|
|
|
|
tokenizer: {
|
|
|
|
},
|
|
|
|
url: {
|
|
|
|
},
|
|
|
|
max_prompt_tokens: {
|
|
|
|
},
|
|
|
|
max_output_tokens: {
|
|
|
|
},
|
|
|
|
enabled_chat_bot: {
|
|
|
|
},
|
|
|
|
vision_enabled: {
|
|
|
|
},
|
|
|
|
api_key: {
|
|
|
|
type: :sensitive,
|
|
|
|
},
|
|
|
|
input_cost: {
|
|
|
|
},
|
|
|
|
output_cost: {
|
|
|
|
},
|
|
|
|
# JSON fields should be tracked as simple changes
|
|
|
|
json_fields: [:provider_params],
|
|
|
|
}
|
|
|
|
end
|
|
|
|
|
|
|
|
def log_llm_model_creation(llm_model)
|
|
|
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
|
|
|
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
|
|
|
|
|
|
|
|
# Add quota information as a special case
|
|
|
|
if llm_model.llm_quotas.any?
|
|
|
|
entity_details[:quotas] = llm_model
|
|
|
|
.llm_quotas
|
|
|
|
.map do |quota|
|
|
|
|
"Group #{quota.group_id}: #{quota.max_tokens} tokens, #{quota.max_usages} usages, #{quota.duration_seconds}s"
|
|
|
|
end
|
|
|
|
.join("; ")
|
|
|
|
end
|
|
|
|
|
|
|
|
logger.log_creation("llm_model", llm_model, ai_llm_logger_fields, entity_details)
|
|
|
|
end
|
|
|
|
|
|
|
|
def log_llm_model_update(llm_model, initial_attributes, initial_quotas)
|
|
|
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
|
|
|
entity_details = { model_id: llm_model.id, subject: llm_model.display_name }
|
|
|
|
|
|
|
|
# Track quota changes separately as they're a special case
|
|
|
|
current_quotas = llm_model.llm_quotas.reload.map(&:attributes)
|
|
|
|
if initial_quotas != current_quotas
|
|
|
|
initial_quota_summary =
|
|
|
|
initial_quotas
|
|
|
|
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
|
|
|
|
.join("; ")
|
|
|
|
current_quota_summary =
|
|
|
|
current_quotas
|
|
|
|
.map { |q| "Group #{q["group_id"]}: #{q["max_tokens"]} tokens" }
|
|
|
|
.join("; ")
|
|
|
|
entity_details[:quotas_changed] = true
|
|
|
|
entity_details[:quotas] = "#{initial_quota_summary} → #{current_quota_summary}"
|
|
|
|
end
|
|
|
|
|
|
|
|
logger.log_update(
|
|
|
|
"llm_model",
|
|
|
|
llm_model,
|
|
|
|
initial_attributes,
|
|
|
|
ai_llm_logger_fields,
|
|
|
|
entity_details,
|
|
|
|
)
|
|
|
|
end
|
|
|
|
|
|
|
|
def log_llm_model_deletion(model_details)
|
|
|
|
logger = DiscourseAi::Utils::AiStaffActionLogger.new(current_user)
|
|
|
|
model_details[:subject] = model_details[:display_name]
|
|
|
|
logger.log_deletion("llm_model", model_details)
|
|
|
|
end
|
2024-05-13 12:46:42 -03:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|