mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-06-22 07:32:15 +00:00
FEATURE: llm quotas (#1047)
Adds a comprehensive quota management system for LLM models that allows: - Setting per-group (applied per user in the group) token and usage limits with configurable durations - Tracking and enforcing token/usage limits across user groups - Quota reset periods (hourly, daily, weekly, or custom) - Admin UI for managing quotas with real-time updates This system provides granular control over LLM API usage by allowing admins to define limits on both total tokens and number of requests per group. Supports multiple concurrent quotas per model and automatically handles quota resets. Co-authored-by: Keegan George <kgeorge13@gmail.com>
This commit is contained in:
parent
20612fde52
commit
d07cf51653
@ -1,3 +1,4 @@
|
||||
< 3.4.0.beta4-dev: 20612fde52d3f740cad64823ef8aadb0748b567f
|
||||
< 3.4.0.beta3-dev: decf1bb49d737ea15308400f22f89d1d1e71d13d
|
||||
< 3.4.0.beta1-dev: 9d887ad4ace8e33c3fe7dbb39237e882c08b4f0b
|
||||
< 3.3.0.beta5-dev: 4d8090002f6dcd8e34d41033606bf131fa221475
|
||||
|
@ -0,0 +1,59 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module DiscourseAi
|
||||
module Admin
|
||||
class AiLlmQuotasController < ::Admin::AdminController
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
def index
|
||||
quotas = LlmQuota.includes(:group)
|
||||
|
||||
render json: {
|
||||
quotas:
|
||||
ActiveModel::ArraySerializer.new(quotas, each_serializer: LlmQuotaSerializer),
|
||||
}
|
||||
end
|
||||
|
||||
def create
|
||||
quota = LlmQuota.new(quota_params)
|
||||
|
||||
if quota.save
|
||||
render json: LlmQuotaSerializer.new(quota), status: :created
|
||||
else
|
||||
render_json_error quota
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
quota = LlmQuota.find(params[:id])
|
||||
|
||||
if quota.update(quota_params)
|
||||
render json: LlmQuotaSerializer.new(quota)
|
||||
else
|
||||
render_json_error quota
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
quota = LlmQuota.find(params[:id])
|
||||
quota.destroy!
|
||||
|
||||
head :no_content
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: I18n.t("not_found") }, status: 404
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def quota_params
|
||||
params.require(:quota).permit(
|
||||
:group_id,
|
||||
:llm_model_id,
|
||||
:max_tokens,
|
||||
:max_usages,
|
||||
:duration_seconds,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
@ -6,7 +6,7 @@ module DiscourseAi
|
||||
requires_plugin ::DiscourseAi::PLUGIN_NAME
|
||||
|
||||
def index
|
||||
llms = LlmModel.all.order(:display_name)
|
||||
llms = LlmModel.all.includes(:llm_quotas).order(:display_name)
|
||||
|
||||
render json: {
|
||||
ai_llms:
|
||||
@ -40,6 +40,11 @@ module DiscourseAi
|
||||
|
||||
def create
|
||||
llm_model = LlmModel.new(ai_llm_params)
|
||||
|
||||
# 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
|
||||
|
||||
if llm_model.save
|
||||
llm_model.toggle_companion_user
|
||||
render json: LlmModelSerializer.new(llm_model), status: :created
|
||||
@ -51,6 +56,25 @@ module DiscourseAi
|
||||
def update
|
||||
llm_model = LlmModel.find(params[:id])
|
||||
|
||||
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
|
||||
|
||||
if llm_model.seeded?
|
||||
return render_json_error(I18n.t("discourse_ai.llm.cannot_edit_builtin"), status: 403)
|
||||
end
|
||||
@ -110,6 +134,19 @@ module DiscourseAi
|
||||
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def ai_llm_params(updating: nil)
|
||||
return {} if params[:ai_llm].blank?
|
||||
|
||||
|
@ -4,6 +4,7 @@ class LlmModel < ActiveRecord::Base
|
||||
FIRST_BOT_USER_ID = -1200
|
||||
BEDROCK_PROVIDER_NAME = "aws_bedrock"
|
||||
|
||||
has_many :llm_quotas, dependent: :destroy
|
||||
belongs_to :user
|
||||
|
||||
validates :display_name, presence: true, length: { maximum: 100 }
|
||||
|
85
app/models/llm_quota.rb
Normal file
85
app/models/llm_quota.rb
Normal file
@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LlmQuota < ActiveRecord::Base
|
||||
self.table_name = "llm_quotas"
|
||||
|
||||
belongs_to :group
|
||||
belongs_to :llm_model
|
||||
has_many :llm_quota_usages
|
||||
|
||||
validates :group_id, presence: true
|
||||
# we can not validate on create cause it breaks build
|
||||
validates :llm_model_id, presence: true, on: :update
|
||||
validates :duration_seconds, presence: true, numericality: { greater_than: 0 }
|
||||
validates :max_tokens, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
|
||||
validates :max_usages, numericality: { greater_than: 0, allow_nil: true }
|
||||
|
||||
validate :at_least_one_limit
|
||||
|
||||
def self.check_quotas!(llm, user)
|
||||
return true if user.blank?
|
||||
quotas = joins(:group).where(llm_model: llm).where(group: user.groups)
|
||||
|
||||
return true if quotas.empty?
|
||||
errors =
|
||||
quotas.map do |quota|
|
||||
usage = LlmQuotaUsage.find_or_create_for(user: user, llm_quota: quota)
|
||||
begin
|
||||
usage.check_quota!
|
||||
nil
|
||||
rescue LlmQuotaUsage::QuotaExceededError => e
|
||||
e
|
||||
end
|
||||
end
|
||||
|
||||
return if errors.include?(nil)
|
||||
|
||||
raise errors.first
|
||||
end
|
||||
|
||||
def self.log_usage(llm, user, input_tokens, output_tokens)
|
||||
return if user.blank?
|
||||
|
||||
quotas = joins(:group).where(llm_model: llm).where(group: user.groups)
|
||||
|
||||
quotas.each do |quota|
|
||||
usage = LlmQuotaUsage.find_or_create_for(user: user, llm_quota: quota)
|
||||
usage.increment_usage!(input_tokens: input_tokens, output_tokens: output_tokens)
|
||||
end
|
||||
end
|
||||
|
||||
def available_tokens
|
||||
max_tokens
|
||||
end
|
||||
|
||||
def available_usages
|
||||
max_usages
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def at_least_one_limit
|
||||
if max_tokens.nil? && max_usages.nil?
|
||||
errors.add(:base, I18n.t("discourse_ai.errors.quota_required"))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: llm_quotas
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# group_id :bigint not null
|
||||
# llm_model_id :bigint not null
|
||||
# max_tokens :integer
|
||||
# max_usages :integer
|
||||
# duration_seconds :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_llm_quotas_on_group_id_and_llm_model_id (group_id,llm_model_id) UNIQUE
|
||||
# index_llm_quotas_on_llm_model_id (llm_model_id)
|
||||
#
|
120
app/models/llm_quota_usage.rb
Normal file
120
app/models/llm_quota_usage.rb
Normal file
@ -0,0 +1,120 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LlmQuotaUsage < ActiveRecord::Base
|
||||
self.table_name = "llm_quota_usages"
|
||||
|
||||
QuotaExceededError = Class.new(StandardError)
|
||||
|
||||
belongs_to :user
|
||||
belongs_to :llm_quota
|
||||
|
||||
validates :user_id, presence: true
|
||||
validates :llm_quota_id, presence: true
|
||||
validates :input_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :output_tokens_used, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :usages, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :started_at, presence: true
|
||||
validates :reset_at, presence: true
|
||||
|
||||
def self.find_or_create_for(user:, llm_quota:)
|
||||
usage = find_or_initialize_by(user: user, llm_quota: llm_quota)
|
||||
|
||||
if usage.new_record?
|
||||
now = Time.current
|
||||
usage.started_at = now
|
||||
usage.reset_at = now + llm_quota.duration_seconds.seconds
|
||||
usage.input_tokens_used = 0
|
||||
usage.output_tokens_used = 0
|
||||
usage.usages = 0
|
||||
usage.save!
|
||||
end
|
||||
|
||||
usage
|
||||
end
|
||||
|
||||
def reset_if_needed!
|
||||
return if Time.current < reset_at
|
||||
|
||||
now = Time.current
|
||||
update!(
|
||||
input_tokens_used: 0,
|
||||
output_tokens_used: 0,
|
||||
usages: 0,
|
||||
started_at: now,
|
||||
reset_at: now + llm_quota.duration_seconds.seconds,
|
||||
)
|
||||
end
|
||||
|
||||
def increment_usage!(input_tokens:, output_tokens:)
|
||||
reset_if_needed!
|
||||
|
||||
increment!(:usages)
|
||||
increment!(:input_tokens_used, input_tokens)
|
||||
increment!(:output_tokens_used, output_tokens)
|
||||
end
|
||||
|
||||
def check_quota!
|
||||
reset_if_needed!
|
||||
|
||||
if quota_exceeded?
|
||||
raise QuotaExceededError.new(
|
||||
I18n.t(
|
||||
"discourse_ai.errors.quota_exceeded",
|
||||
relative_time: AgeWords.distance_of_time_in_words(reset_at, Time.now),
|
||||
),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def quota_exceeded?
|
||||
return false if !llm_quota
|
||||
|
||||
(llm_quota.max_tokens.present? && total_tokens_used > llm_quota.max_tokens) ||
|
||||
(llm_quota.max_usages.present? && usages > llm_quota.max_usages)
|
||||
end
|
||||
|
||||
def total_tokens_used
|
||||
input_tokens_used + output_tokens_used
|
||||
end
|
||||
|
||||
def remaining_tokens
|
||||
return nil if llm_quota.max_tokens.nil?
|
||||
[0, llm_quota.max_tokens - total_tokens_used].max
|
||||
end
|
||||
|
||||
def remaining_usages
|
||||
return nil if llm_quota.max_usages.nil?
|
||||
[0, llm_quota.max_usages - usages].max
|
||||
end
|
||||
|
||||
def percentage_tokens_used
|
||||
return 0 if llm_quota.max_tokens.nil? || llm_quota.max_tokens.zero?
|
||||
[(total_tokens_used.to_f / llm_quota.max_tokens * 100).round, 100].min
|
||||
end
|
||||
|
||||
def percentage_usages_used
|
||||
return 0 if llm_quota.max_usages.nil? || llm_quota.max_usages.zero?
|
||||
[(usages.to_f / llm_quota.max_usages * 100).round, 100].min
|
||||
end
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: llm_quota_usages
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :bigint not null
|
||||
# llm_quota_id :bigint not null
|
||||
# input_tokens_used :integer not null
|
||||
# output_tokens_used :integer not null
|
||||
# usages :integer not null
|
||||
# started_at :datetime not null
|
||||
# reset_at :datetime not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_llm_quota_usages_on_llm_quota_id (llm_quota_id)
|
||||
# index_llm_quota_usages_on_user_id_and_llm_quota_id (user_id,llm_quota_id) UNIQUE
|
||||
#
|
@ -20,6 +20,7 @@ class LlmModelSerializer < ApplicationSerializer
|
||||
:used_by
|
||||
|
||||
has_one :user, serializer: BasicUserSerializer, embed: :object
|
||||
has_many :llm_quotas, serializer: LlmQuotaSerializer, embed: :objects
|
||||
|
||||
def used_by
|
||||
llm_usage =
|
||||
|
9
app/serializers/llm_quota_serializer.rb
Normal file
9
app/serializers/llm_quota_serializer.rb
Normal file
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class LlmQuotaSerializer < ApplicationSerializer
|
||||
attributes :id, :group_id, :llm_model_id, :max_tokens, :max_usages, :duration_seconds, :group_name
|
||||
|
||||
def group_name
|
||||
object.group.name
|
||||
end
|
||||
end
|
@ -21,7 +21,7 @@ export default class AiLlm extends RestModel {
|
||||
updateProperties() {
|
||||
const attrs = this.createProperties();
|
||||
attrs.id = this.id;
|
||||
|
||||
attrs.llm_quotas = this.llm_quotas;
|
||||
return attrs;
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ import { tracked } from "@glimmer/tracking";
|
||||
import { action } from "@ember/object";
|
||||
import { getOwner } from "@ember/owner";
|
||||
import { service } from "@ember/service";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DToast from "float-kit/components/d-toast";
|
||||
import DToastInstance from "float-kit/lib/d-toast-instance";
|
||||
import AiHelperOptionsList from "../components/ai-helper-options-list";
|
||||
@ -45,7 +45,7 @@ export default class AiComposerHelperMenu extends Component {
|
||||
this.siteSettings.available_locales
|
||||
);
|
||||
const locale = availableLocales.find((l) => l.value === siteLocale);
|
||||
const translatedName = I18n.t(
|
||||
const translatedName = i18n(
|
||||
"discourse_ai.ai_helper.context_menu.translate_prompt",
|
||||
{
|
||||
language: locale.name,
|
||||
@ -90,7 +90,7 @@ export default class AiComposerHelperMenu extends Component {
|
||||
data: {
|
||||
theme: "error",
|
||||
icon: "triangle-exclamation",
|
||||
message: I18n.t("discourse_ai.ai_helper.no_content_error"),
|
||||
message: i18n("discourse_ai.ai_helper.no_content_error"),
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { computed } from "@ember/object";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
|
||||
export default ComboBox.extend({
|
||||
@ -7,14 +7,14 @@ export default ComboBox.extend({
|
||||
const content = [
|
||||
{
|
||||
id: -1,
|
||||
name: I18n.t("discourse_ai.ai_persona.tool_strategies.all"),
|
||||
name: i18n("discourse_ai.ai_persona.tool_strategies.all"),
|
||||
},
|
||||
];
|
||||
|
||||
[1, 2, 5].forEach((i) => {
|
||||
content.push({
|
||||
id: i,
|
||||
name: I18n.t("discourse_ai.ai_persona.tool_strategies.replies", {
|
||||
name: i18n("discourse_ai.ai_persona.tool_strategies.replies", {
|
||||
count: i,
|
||||
}),
|
||||
});
|
||||
|
@ -12,11 +12,12 @@ import DButton from "discourse/components/d-button";
|
||||
import Avatar from "discourse/helpers/bound-avatar-template";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AdminUser from "admin/models/admin-user";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
import AiLlmQuotaEditor from "./ai-llm-quota-editor";
|
||||
import AiLlmQuotaModal from "./modal/ai-llm-quota-modal";
|
||||
|
||||
export default class AiLlmEditorForm extends Component {
|
||||
@service toasts;
|
||||
@ -29,10 +30,18 @@ export default class AiLlmEditorForm extends Component {
|
||||
@tracked testResult = null;
|
||||
@tracked testError = null;
|
||||
@tracked apiKeySecret = true;
|
||||
@tracked quotaCount = 0;
|
||||
|
||||
@tracked modalIsVisible = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.updateQuotaCount();
|
||||
}
|
||||
|
||||
get selectedProviders() {
|
||||
const t = (provName) => {
|
||||
return I18n.t(`discourse_ai.llms.providers.${provName}`);
|
||||
return i18n(`discourse_ai.llms.providers.${provName}`);
|
||||
};
|
||||
|
||||
return this.args.llms.resultSetMeta.providers.map((prov) => {
|
||||
@ -45,7 +54,7 @@ export default class AiLlmEditorForm extends Component {
|
||||
}
|
||||
|
||||
get testErrorMessage() {
|
||||
return I18n.t("discourse_ai.llms.tests.failure", { error: this.testError });
|
||||
return i18n("discourse_ai.llms.tests.failure", { error: this.testError });
|
||||
}
|
||||
|
||||
get displayTestResult() {
|
||||
@ -65,7 +74,7 @@ export default class AiLlmEditorForm extends Component {
|
||||
}
|
||||
|
||||
const localized = usedBy.map((m) => {
|
||||
return I18n.t(`discourse_ai.llms.usage.${m.type}`, {
|
||||
return i18n(`discourse_ai.llms.usage.${m.type}`, {
|
||||
persona: m.name,
|
||||
});
|
||||
});
|
||||
@ -79,12 +88,30 @@ export default class AiLlmEditorForm extends Component {
|
||||
}
|
||||
|
||||
get inUseWarning() {
|
||||
return I18n.t("discourse_ai.llms.in_use_warning", {
|
||||
return i18n("discourse_ai.llms.in_use_warning", {
|
||||
settings: this.modulesUsingModel,
|
||||
count: this.args.model.used_by.length,
|
||||
});
|
||||
}
|
||||
|
||||
get showQuotas() {
|
||||
return this.quotaCount > 0;
|
||||
}
|
||||
|
||||
get showAddQuotaButton() {
|
||||
return !this.showQuotas && !this.args.model.isNew;
|
||||
}
|
||||
|
||||
@action
|
||||
updateQuotaCount() {
|
||||
this.quotaCount = this.args.model?.llm_quotas?.length || 0;
|
||||
}
|
||||
|
||||
@action
|
||||
openAddQuotaModal() {
|
||||
this.modalIsVisible = true;
|
||||
}
|
||||
|
||||
@computed("args.model.provider")
|
||||
get metaProviderParams() {
|
||||
return (
|
||||
@ -106,7 +133,7 @@ export default class AiLlmEditorForm extends Component {
|
||||
this.router.transitionTo("adminPlugins.show.discourse-ai-llms.index");
|
||||
} else {
|
||||
this.toasts.success({
|
||||
data: { message: I18n.t("discourse_ai.llms.saved") },
|
||||
data: { message: i18n("discourse_ai.llms.saved") },
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
@ -154,7 +181,7 @@ export default class AiLlmEditorForm extends Component {
|
||||
@action
|
||||
delete() {
|
||||
return this.dialog.confirm({
|
||||
message: I18n.t("discourse_ai.llms.confirm_delete"),
|
||||
message: i18n("discourse_ai.llms.confirm_delete"),
|
||||
didConfirm: () => {
|
||||
return this.args.model
|
||||
.destroyRecord()
|
||||
@ -169,6 +196,12 @@ export default class AiLlmEditorForm extends Component {
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
closeAddQuotaModal() {
|
||||
this.modalIsVisible = false;
|
||||
this.updateQuotaCount();
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.seeded}}
|
||||
<div class="alert alert-info">
|
||||
@ -317,6 +350,17 @@ export default class AiLlmEditorForm extends Component {
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showQuotas}}
|
||||
<div class="control-group">
|
||||
<label>{{i18n "discourse_ai.llms.quotas.title"}}</label>
|
||||
<AiLlmQuotaEditor
|
||||
@model={{@model}}
|
||||
@groups={{@groups}}
|
||||
@didUpdate={{this.updateQuotaCount}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div class="control-group ai-llm-editor__action_panel">
|
||||
<DButton
|
||||
class="ai-llm-editor__test"
|
||||
@ -324,7 +368,19 @@ export default class AiLlmEditorForm extends Component {
|
||||
@disabled={{this.testRunning}}
|
||||
@label="discourse_ai.llms.tests.title"
|
||||
/>
|
||||
|
||||
{{#if this.showAddQuotaButton}}
|
||||
<DButton
|
||||
@action={{this.openAddQuotaModal}}
|
||||
@label="discourse_ai.llms.quotas.add"
|
||||
class="btn"
|
||||
/>
|
||||
{{#if this.modalIsVisible}}
|
||||
<AiLlmQuotaModal
|
||||
@model={{hash llm=@model}}
|
||||
@closeModal={{this.closeAddQuotaModal}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<DButton
|
||||
class="btn-primary ai-llm-editor__save"
|
||||
@action={{this.save}}
|
||||
|
178
assets/javascripts/discourse/components/ai-llm-quota-editor.gjs
Normal file
178
assets/javascripts/discourse/components/ai-llm-quota-editor.gjs
Normal file
@ -0,0 +1,178 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { fn, hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DurationSelector from "./ai-quota-duration-selector";
|
||||
import AiLlmQuotaModal from "./modal/ai-llm-quota-modal";
|
||||
|
||||
export default class AiLlmQuotaEditor extends Component {
|
||||
@service store;
|
||||
@service dialog;
|
||||
@service site;
|
||||
|
||||
@tracked newQuotaGroupIds = null;
|
||||
@tracked newQuotaTokens = null;
|
||||
@tracked newQuotaUsages = null;
|
||||
@tracked newQuotaDuration = 86400; // 1 day default
|
||||
@tracked modalIsVisible = false;
|
||||
|
||||
@action
|
||||
updateExistingQuotaTokens(quota, event) {
|
||||
quota.max_tokens = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateExistingQuotaUsages(quota, event) {
|
||||
quota.max_usages = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateExistingQuotaDuration(quota, value) {
|
||||
quota.duration_seconds = value;
|
||||
}
|
||||
|
||||
@action
|
||||
openAddQuotaModal() {
|
||||
this.modalIsVisible = true;
|
||||
}
|
||||
|
||||
get canAddQuota() {
|
||||
return (
|
||||
this.newQuotaGroupId &&
|
||||
(this.newQuotaTokens || this.newQuotaUsages) &&
|
||||
this.newQuotaDuration
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
updateQuotaTokens(event) {
|
||||
this.newQuotaTokens = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateQuotaUsages(event) {
|
||||
this.newQuotaUsages = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateQuotaDuration(event) {
|
||||
this.newQuotaDuration = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateGroups(groups) {
|
||||
this.newQuotaGroupIds = groups;
|
||||
}
|
||||
|
||||
@action
|
||||
async addQuota() {
|
||||
const quota = {
|
||||
group_id: this.newQuotaGroupIds[0],
|
||||
group_name: this.site.groups.findBy("id", this.newQuotaGroupIds[0])?.name,
|
||||
llm_model_id: this.args.model.id,
|
||||
max_tokens: this.newQuotaTokens,
|
||||
max_usages: this.newQuotaUsages,
|
||||
duration_seconds: this.newQuotaDuration,
|
||||
};
|
||||
this.args.model.llm_quotas.pushObject(quota);
|
||||
if (this.args.didUpdate) {
|
||||
this.args.didUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async deleteQuota(quota) {
|
||||
this.args.model.llm_quotas.removeObject(quota);
|
||||
if (this.args.didUpdate) {
|
||||
this.args.didUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
closeAddQuotaModal() {
|
||||
this.modalIsVisible = false;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="ai-llm-quotas">
|
||||
<table class="ai-llm-quotas__table">
|
||||
<thead class="ai-llm-quotas__table-head">
|
||||
<tr class="ai-llm-quotas__header-row">
|
||||
<th class="ai-llm-quotas__header">{{i18n
|
||||
"discourse_ai.llms.quotas.group"
|
||||
}}</th>
|
||||
<th class="ai-llm-quotas__header">{{i18n
|
||||
"discourse_ai.llms.quotas.max_tokens"
|
||||
}}</th>
|
||||
<th class="ai-llm-quotas__header">{{i18n
|
||||
"discourse_ai.llms.quotas.max_usages"
|
||||
}}</th>
|
||||
<th class="ai-llm-quotas__header">{{i18n
|
||||
"discourse_ai.llms.quotas.duration"
|
||||
}}</th>
|
||||
<th
|
||||
class="ai-llm-quotas__header ai-llm-quotas__header--actions"
|
||||
></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="ai-llm-quotas__table-body">
|
||||
{{#each @model.llm_quotas as |quota|}}
|
||||
<tr class="ai-llm-quotas__row">
|
||||
<td class="ai-llm-quotas__cell">{{quota.group_name}}</td>
|
||||
<td class="ai-llm-quotas__cell">
|
||||
<input
|
||||
type="number"
|
||||
value={{quota.max_tokens}}
|
||||
class="ai-llm-quotas__input"
|
||||
min="1"
|
||||
{{on "input" (fn this.updateExistingQuotaTokens quota)}}
|
||||
/>
|
||||
</td>
|
||||
<td class="ai-llm-quotas__cell">
|
||||
<input
|
||||
type="number"
|
||||
value={{quota.max_usages}}
|
||||
class="ai-llm-quotas__input"
|
||||
min="1"
|
||||
{{on "input" (fn this.updateExistingQuotaUsages quota)}}
|
||||
/>
|
||||
</td>
|
||||
<td class="ai-llm-quotas__cell">
|
||||
<DurationSelector
|
||||
@value={{quota.duration_seconds}}
|
||||
@onChange={{fn this.updateExistingQuotaDuration quota}}
|
||||
/>
|
||||
</td>
|
||||
<td class="ai-llm-quotas__cell ai-llm-quotas__cell--actions">
|
||||
<DButton
|
||||
@icon="trash-alt"
|
||||
class="btn-danger ai-llm-quotas__delete-btn"
|
||||
@action={{fn this.deleteQuota quota}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ai-llm-quotas__actions">
|
||||
<DButton
|
||||
@action={{this.openAddQuotaModal}}
|
||||
@icon="plus"
|
||||
@label="discourse_ai.llms.quotas.add"
|
||||
class="btn"
|
||||
/>
|
||||
|
||||
{{#if this.modalIsVisible}}
|
||||
<AiLlmQuotaModal
|
||||
@model={{hash llm=@model}}
|
||||
@closeModal={{this.closeAddQuotaModal}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { computed } from "@ember/object";
|
||||
import { observes } from "@ember-decorators/object";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
import { selectKitOptions } from "select-kit/components/select-kit";
|
||||
|
||||
@ -18,7 +18,7 @@ export default class AiLlmSelector extends ComboBox {
|
||||
return [
|
||||
{
|
||||
id: "blank",
|
||||
name: I18n.t("discourse_ai.ai_persona.no_llm_selected"),
|
||||
name: i18n("discourse_ai.ai_persona.no_llm_selected"),
|
||||
},
|
||||
].concat(this.llms);
|
||||
}
|
||||
|
@ -5,8 +5,7 @@ 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 i18n from "discourse-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
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";
|
||||
@ -38,7 +37,7 @@ export default class AiLlmsListEditor extends Component {
|
||||
|
||||
key = `discourse_ai.llms.model_description.${key}`;
|
||||
if (I18n.lookup(key, { ignoreMissing: true })) {
|
||||
return I18n.t(key);
|
||||
return i18n(key);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@ -72,7 +71,7 @@ export default class AiLlmsListEditor extends Component {
|
||||
const options = [
|
||||
{
|
||||
id: "none",
|
||||
name: I18n.t("discourse_ai.llms.preconfigured.fake"),
|
||||
name: i18n("discourse_ai.llms.preconfigured.fake"),
|
||||
provider: "fake",
|
||||
},
|
||||
];
|
||||
@ -114,11 +113,11 @@ export default class AiLlmsListEditor extends Component {
|
||||
|
||||
localizeUsage(usage) {
|
||||
if (usage.type === "ai_persona") {
|
||||
return I18n.t("discourse_ai.llms.usage.ai_persona", {
|
||||
return i18n("discourse_ai.llms.usage.ai_persona", {
|
||||
persona: usage.name,
|
||||
});
|
||||
} else {
|
||||
return I18n.t("discourse_ai.llms.usage." + usage.type);
|
||||
return i18n("discourse_ai.llms.usage." + usage.type);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -15,7 +15,7 @@ import DToggleSwitch from "discourse/components/d-toggle-switch";
|
||||
import Avatar from "discourse/helpers/bound-avatar-template";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Group from "discourse/models/group";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AdminUser from "admin/models/admin-user";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
import GroupChooser from "select-kit/components/group-chooser";
|
||||
@ -108,7 +108,7 @@ export default class PersonaEditor extends Component {
|
||||
@cached
|
||||
get maxPixelValues() {
|
||||
const l = (key) =>
|
||||
I18n.t(`discourse_ai.ai_persona.vision_max_pixel_sizes.${key}`);
|
||||
i18n(`discourse_ai.ai_persona.vision_max_pixel_sizes.${key}`);
|
||||
return [
|
||||
{ id: "low", name: l("low"), pixels: 65536 },
|
||||
{ id: "medium", name: l("medium"), pixels: 262144 },
|
||||
@ -140,7 +140,7 @@ export default class PersonaEditor extends Component {
|
||||
);
|
||||
} else {
|
||||
this.toasts.success({
|
||||
data: { message: I18n.t("discourse_ai.ai_persona.saved") },
|
||||
data: { message: i18n("discourse_ai.ai_persona.saved") },
|
||||
duration: 2000,
|
||||
});
|
||||
}
|
||||
@ -205,7 +205,7 @@ export default class PersonaEditor extends Component {
|
||||
@action
|
||||
delete() {
|
||||
return this.dialog.confirm({
|
||||
message: I18n.t("discourse_ai.ai_persona.confirm_delete"),
|
||||
message: i18n("discourse_ai.ai_persona.confirm_delete"),
|
||||
didConfirm: () => {
|
||||
return this.args.model.destroyRecord().then(() => {
|
||||
this.args.personas.removeObject(this.args.model);
|
||||
@ -316,11 +316,11 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.priority_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.priority_help"}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.name"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.name"}}</label>
|
||||
<Input
|
||||
class="ai-persona-editor__name"
|
||||
@type="text"
|
||||
@ -329,7 +329,7 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.description"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.description"}}</label>
|
||||
<Textarea
|
||||
class="ai-persona-editor__description"
|
||||
@value={{this.editingModel.description}}
|
||||
@ -338,7 +338,7 @@ export default class PersonaEditor extends Component {
|
||||
</div>
|
||||
{{#if this.editingModel.user}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.default_llm"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.default_llm"}}</label>
|
||||
<AiLlmSelector
|
||||
class="ai-persona-editor__llms"
|
||||
@value={{this.mappedDefaultLlm}}
|
||||
@ -346,7 +346,7 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.default_llm_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.default_llm_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.hasDefaultLlm}}
|
||||
@ -356,13 +356,13 @@ export default class PersonaEditor extends Component {
|
||||
@type="checkbox"
|
||||
@checked={{this.editingModel.force_default_llm}}
|
||||
/>
|
||||
{{I18n.t "discourse_ai.ai_persona.force_default_llm"}}</label>
|
||||
{{i18n "discourse_ai.ai_persona.force_default_llm"}}</label>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#unless @model.isNew}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.user"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.user"}}</label>
|
||||
{{#if this.editingModel.user}}
|
||||
<a
|
||||
class="avatar"
|
||||
@ -379,17 +379,17 @@ export default class PersonaEditor extends Component {
|
||||
@action={{this.createUser}}
|
||||
class="ai-persona-editor__create-user"
|
||||
>
|
||||
{{I18n.t "discourse_ai.ai_persona.create_user"}}
|
||||
{{i18n "discourse_ai.ai_persona.create_user"}}
|
||||
</DButton>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.create_user_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.create_user_help"}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.tools"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.tools"}}</label>
|
||||
<AiToolSelector
|
||||
class="ai-persona-editor__tools"
|
||||
@value={{this.selectedToolNames}}
|
||||
@ -400,7 +400,7 @@ export default class PersonaEditor extends Component {
|
||||
</div>
|
||||
{{#if this.allowForceTools}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.forced_tools"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.forced_tools"}}</label>
|
||||
<AiToolSelector
|
||||
class="ai-persona-editor__forced_tools"
|
||||
@value={{this.forcedToolNames}}
|
||||
@ -410,7 +410,7 @@ export default class PersonaEditor extends Component {
|
||||
</div>
|
||||
{{#if this.hasForcedTools}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t
|
||||
<label>{{i18n
|
||||
"discourse_ai.ai_persona.forced_tool_strategy"
|
||||
}}</label>
|
||||
<AiForcedToolStrategySelector
|
||||
@ -428,7 +428,7 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
{{/unless}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.allowed_groups"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.allowed_groups"}}</label>
|
||||
<GroupChooser
|
||||
@value={{this.editingModel.allowed_group_ids}}
|
||||
@content={{this.allGroups}}
|
||||
@ -436,7 +436,7 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label for="ai-persona-editor__system_prompt">{{I18n.t
|
||||
<label for="ai-persona-editor__system_prompt">{{i18n
|
||||
"discourse_ai.ai_persona.system_prompt"
|
||||
}}</label>
|
||||
<Textarea
|
||||
@ -451,10 +451,10 @@ export default class PersonaEditor extends Component {
|
||||
@type="checkbox"
|
||||
@checked={{this.editingModel.allow_personal_messages}}
|
||||
/>
|
||||
{{I18n.t "discourse_ai.ai_persona.allow_personal_messages"}}</label>
|
||||
{{i18n "discourse_ai.ai_persona.allow_personal_messages"}}</label>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t
|
||||
@content={{i18n
|
||||
"discourse_ai.ai_persona.allow_personal_messages_help"
|
||||
}}
|
||||
/>
|
||||
@ -466,10 +466,10 @@ export default class PersonaEditor extends Component {
|
||||
@type="checkbox"
|
||||
@checked={{this.editingModel.allow_topic_mentions}}
|
||||
/>
|
||||
{{I18n.t "discourse_ai.ai_persona.allow_topic_mentions"}}</label>
|
||||
{{i18n "discourse_ai.ai_persona.allow_topic_mentions"}}</label>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t
|
||||
@content={{i18n
|
||||
"discourse_ai.ai_persona.allow_topic_mentions_help"
|
||||
}}
|
||||
/>
|
||||
@ -483,12 +483,12 @@ export default class PersonaEditor extends Component {
|
||||
@type="checkbox"
|
||||
@checked={{this.editingModel.allow_chat_direct_messages}}
|
||||
/>
|
||||
{{I18n.t
|
||||
{{i18n
|
||||
"discourse_ai.ai_persona.allow_chat_direct_messages"
|
||||
}}</label>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t
|
||||
@content={{i18n
|
||||
"discourse_ai.ai_persona.allow_chat_direct_messages_help"
|
||||
}}
|
||||
/>
|
||||
@ -501,12 +501,12 @@ export default class PersonaEditor extends Component {
|
||||
@type="checkbox"
|
||||
@checked={{this.editingModel.allow_chat_channel_mentions}}
|
||||
/>
|
||||
{{I18n.t
|
||||
{{i18n
|
||||
"discourse_ai.ai_persona.allow_chat_channel_mentions"
|
||||
}}</label>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t
|
||||
@content={{i18n
|
||||
"discourse_ai.ai_persona.allow_chat_channel_mentions_help"
|
||||
}}
|
||||
/>
|
||||
@ -516,10 +516,10 @@ export default class PersonaEditor extends Component {
|
||||
<div class="control-group ai-persona-editor__tool-details">
|
||||
<label>
|
||||
<Input @type="checkbox" @checked={{this.editingModel.tool_details}} />
|
||||
{{I18n.t "discourse_ai.ai_persona.tool_details"}}</label>
|
||||
{{i18n "discourse_ai.ai_persona.tool_details"}}</label>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.tool_details_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.tool_details_help"}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group ai-persona-editor__vision_enabled">
|
||||
@ -528,15 +528,15 @@ export default class PersonaEditor extends Component {
|
||||
@type="checkbox"
|
||||
@checked={{this.editingModel.vision_enabled}}
|
||||
/>
|
||||
{{I18n.t "discourse_ai.ai_persona.vision_enabled"}}</label>
|
||||
{{i18n "discourse_ai.ai_persona.vision_enabled"}}</label>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.vision_enabled_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.vision_enabled_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.editingModel.vision_enabled}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.vision_max_pixels"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.vision_max_pixels"}}</label>
|
||||
<ComboBox
|
||||
@value={{this.maxPixelsValue}}
|
||||
@content={{this.maxPixelValues}}
|
||||
@ -545,7 +545,7 @@ export default class PersonaEditor extends Component {
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.max_context_posts"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.max_context_posts"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
lang="en"
|
||||
@ -554,12 +554,12 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.max_context_posts_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.max_context_posts_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{#if this.showTemperature}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.temperature"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.temperature"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
class="ai-persona-editor__temperature"
|
||||
@ -570,13 +570,13 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.temperature_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.temperature_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if this.showTopP}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.top_p"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.top_p"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
step="any"
|
||||
@ -587,7 +587,7 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.ai_persona.top_p_help"}}
|
||||
@content={{i18n "discourse_ai.ai_persona.top_p_help"}}
|
||||
/>
|
||||
</div>
|
||||
{{/if}}
|
||||
@ -601,7 +601,7 @@ export default class PersonaEditor extends Component {
|
||||
</div>
|
||||
<RagOptions @model={{this.editingModel}}>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t
|
||||
<label>{{i18n
|
||||
"discourse_ai.ai_persona.rag_conversation_chunks"
|
||||
}}</label>
|
||||
<Input
|
||||
@ -613,13 +613,13 @@ export default class PersonaEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t
|
||||
@content={{i18n
|
||||
"discourse_ai.ai_persona.rag_conversation_chunks_help"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t
|
||||
<label>{{i18n
|
||||
"discourse_ai.ai_persona.question_consolidator_llm"
|
||||
}}</label>
|
||||
<AiLlmSelector
|
||||
@ -630,7 +630,7 @@ export default class PersonaEditor extends Component {
|
||||
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t
|
||||
@content={{i18n
|
||||
"discourse_ai.ai_persona.question_consolidator_llm_help"
|
||||
}}
|
||||
/>
|
||||
@ -642,13 +642,13 @@ export default class PersonaEditor extends Component {
|
||||
class="btn-primary ai-persona-editor__save"
|
||||
@action={{this.save}}
|
||||
@disabled={{this.isSaving}}
|
||||
>{{I18n.t "discourse_ai.ai_persona.save"}}</DButton>
|
||||
>{{i18n "discourse_ai.ai_persona.save"}}</DButton>
|
||||
{{#if this.showDelete}}
|
||||
<DButton
|
||||
@action={{this.delete}}
|
||||
class="btn-danger ai-persona-editor__delete"
|
||||
>
|
||||
{{I18n.t "discourse_ai.ai_persona.delete"}}
|
||||
{{i18n "discourse_ai.ai_persona.delete"}}
|
||||
</DButton>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
import Component from "@glimmer/component";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AiPersonaToolOptionEditor from "./ai-persona-tool-option-editor";
|
||||
|
||||
export default class AiPersonaToolOptions extends Component {
|
||||
@ -58,7 +58,7 @@ export default class AiPersonaToolOptions extends Component {
|
||||
<template>
|
||||
{{#if this.showToolOptions}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.ai_persona.tool_options"}}</label>
|
||||
<label>{{i18n "discourse_ai.ai_persona.tool_options"}}</label>
|
||||
<div>
|
||||
{{#each this.toolOptions as |toolOption|}}
|
||||
<div class="ai-persona-editor__tool-options">
|
||||
|
@ -15,7 +15,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { sanitize } from "discourse/lib/text";
|
||||
import { clipboardCopy } from "discourse/lib/utilities";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import eq from "truth-helpers/helpers/eq";
|
||||
import AiHelperLoading from "../components/ai-helper-loading";
|
||||
import AiHelperOptionsList from "../components/ai-helper-options-list";
|
||||
@ -55,7 +55,7 @@ export default class AiPostHelperMenu extends Component {
|
||||
|
||||
const instance = this.tooltip.register(element, {
|
||||
identifier: "cannot-add-footnote-tooltip",
|
||||
content: I18n.t(
|
||||
content: i18n(
|
||||
"discourse_ai.ai_helper.post_options_menu.footnote_disabled"
|
||||
),
|
||||
placement: "top",
|
||||
@ -286,7 +286,7 @@ export default class AiPostHelperMenu extends Component {
|
||||
try {
|
||||
const result = await ajax(`/posts/${this.args.data.post.id}`);
|
||||
const sanitizedSuggestion = this._sanitizeForFootnote(this.suggestion);
|
||||
const credits = I18n.t(
|
||||
const credits = i18n(
|
||||
"discourse_ai.ai_helper.post_options_menu.footnote_credits"
|
||||
);
|
||||
const withFootnote = `${this.args.data.selectedText} ^[${sanitizedSuggestion} (${credits})]`;
|
||||
|
@ -0,0 +1,111 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
|
||||
const DURATION_PRESETS = [
|
||||
{
|
||||
id: "3600",
|
||||
seconds: 3600,
|
||||
nameKey: "discourse_ai.llms.quotas.durations.hour",
|
||||
},
|
||||
{
|
||||
id: "21600",
|
||||
seconds: 21600,
|
||||
nameKey: "discourse_ai.llms.quotas.durations.six_hours",
|
||||
},
|
||||
{
|
||||
id: "86400",
|
||||
seconds: 86400,
|
||||
nameKey: "discourse_ai.llms.quotas.durations.day",
|
||||
},
|
||||
{
|
||||
id: "604800",
|
||||
seconds: 604800,
|
||||
nameKey: "discourse_ai.llms.quotas.durations.week",
|
||||
},
|
||||
{ id: "custom", nameKey: "discourse_ai.llms.quotas.durations.custom" },
|
||||
];
|
||||
|
||||
export default class DurationSelector extends Component {
|
||||
@tracked selectedPresetId = "86400"; // Default to 1 day
|
||||
@tracked customHours = null;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
const seconds = this.args.value;
|
||||
const preset = DURATION_PRESETS.find((p) => p.seconds === seconds);
|
||||
if (preset) {
|
||||
this.selectedPresetId = preset.id;
|
||||
} else {
|
||||
this.selectedPresetId = "custom";
|
||||
this.customHours = Math.round(seconds / 3600);
|
||||
}
|
||||
}
|
||||
|
||||
get presetOptions() {
|
||||
return DURATION_PRESETS.map((preset) => ({
|
||||
id: preset.id,
|
||||
name: i18n(preset.nameKey),
|
||||
seconds: preset.seconds,
|
||||
}));
|
||||
}
|
||||
|
||||
get isCustom() {
|
||||
return this.selectedPresetId === "custom";
|
||||
}
|
||||
|
||||
get currentDurationSeconds() {
|
||||
if (this.isCustom) {
|
||||
return this.customHours ? this.customHours * 3600 : 0;
|
||||
} else {
|
||||
return parseInt(this.selectedPresetId, 10);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
onPresetChange(value) {
|
||||
this.selectedPresetId = value;
|
||||
this.updateValue();
|
||||
}
|
||||
|
||||
@action
|
||||
onCustomHoursChange(event) {
|
||||
this.customHours = parseInt(event.target.value, 10);
|
||||
this.updateValue();
|
||||
}
|
||||
|
||||
updateValue() {
|
||||
if (this.args.onChange) {
|
||||
this.args.onChange(this.currentDurationSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="duration-selector">
|
||||
<ComboBox
|
||||
@content={{this.presetOptions}}
|
||||
@value={{this.selectedPresetId}}
|
||||
@onChange={{this.onPresetChange}}
|
||||
class="duration-selector__preset"
|
||||
/>
|
||||
|
||||
{{#if this.isCustom}}
|
||||
<div class="duration-selector__custom">
|
||||
<input
|
||||
type="number"
|
||||
value={{this.customHours}}
|
||||
class="duration-selector__hours-input"
|
||||
min="1"
|
||||
{{on "input" this.onCustomHoursChange}}
|
||||
/>
|
||||
<span class="duration-selector__hours-label">
|
||||
{{i18n "discourse_ai.llms.quotas.hours"}}
|
||||
</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
@ -12,7 +12,7 @@ import DButton from "discourse/components/d-button";
|
||||
import DTooltip from "discourse/components/d-tooltip";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
import AiToolParameterEditor from "./ai-tool-parameter-editor";
|
||||
import AiToolTestModal from "./modal/ai-tool-test-modal";
|
||||
@ -95,7 +95,7 @@ export default class AiToolEditor extends Component {
|
||||
await this.args.model.save(data);
|
||||
|
||||
this.toasts.success({
|
||||
data: { message: I18n.t("discourse_ai.tools.saved") },
|
||||
data: { message: i18n("discourse_ai.tools.saved") },
|
||||
duration: 2000,
|
||||
});
|
||||
if (!this.args.tools.any((tool) => tool.id === this.args.model.id)) {
|
||||
@ -116,7 +116,7 @@ export default class AiToolEditor extends Component {
|
||||
@action
|
||||
delete() {
|
||||
return this.dialog.confirm({
|
||||
message: I18n.t("discourse_ai.tools.confirm_delete"),
|
||||
message: i18n("discourse_ai.tools.confirm_delete"),
|
||||
didConfirm: async () => {
|
||||
await this.args.model.destroyRecord();
|
||||
|
||||
@ -148,7 +148,7 @@ export default class AiToolEditor extends Component {
|
||||
>
|
||||
{{#if this.showPresets}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.tools.presets"}}</label>
|
||||
<label>{{i18n "discourse_ai.tools.presets"}}</label>
|
||||
<ComboBox
|
||||
@value={{this.presetId}}
|
||||
@content={{this.presets}}
|
||||
@ -165,7 +165,7 @@ export default class AiToolEditor extends Component {
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.tools.name"}}</label>
|
||||
<label>{{i18n "discourse_ai.tools.name"}}</label>
|
||||
<input
|
||||
{{on "input" (withEventValue (fn (mut this.editingModel.name)))}}
|
||||
value={{this.editingModel.name}}
|
||||
@ -174,24 +174,24 @@ export default class AiToolEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.tools.name_help"}}
|
||||
@content={{i18n "discourse_ai.tools.name_help"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.tools.description"}}</label>
|
||||
<label>{{i18n "discourse_ai.tools.description"}}</label>
|
||||
<textarea
|
||||
{{on
|
||||
"input"
|
||||
(withEventValue (fn (mut this.editingModel.description)))
|
||||
}}
|
||||
placeholder={{I18n.t "discourse_ai.tools.description_help"}}
|
||||
placeholder={{i18n "discourse_ai.tools.description_help"}}
|
||||
class="ai-tool-editor__description input-xxlarge"
|
||||
>{{this.editingModel.description}}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.tools.summary"}}</label>
|
||||
<label>{{i18n "discourse_ai.tools.summary"}}</label>
|
||||
<input
|
||||
{{on "input" (withEventValue (fn (mut this.editingModel.summary)))}}
|
||||
value={{this.editingModel.summary}}
|
||||
@ -200,17 +200,17 @@ export default class AiToolEditor extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.tools.summary_help"}}
|
||||
@content={{i18n "discourse_ai.tools.summary_help"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.tools.parameters"}}</label>
|
||||
<label>{{i18n "discourse_ai.tools.parameters"}}</label>
|
||||
<AiToolParameterEditor @parameters={{this.editingModel.parameters}} />
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.tools.script"}}</label>
|
||||
<label>{{i18n "discourse_ai.tools.script"}}</label>
|
||||
<AceEditor
|
||||
@content={{this.editingModel.script}}
|
||||
@onChange={{fn (mut this.editingModel.script)}}
|
||||
|
@ -3,8 +3,7 @@ import { LinkTo } from "@ember/routing";
|
||||
import { service } from "@ember/service";
|
||||
import DBreadcrumbsItem from "discourse/components/d-breadcrumbs-item";
|
||||
import DPageSubheader from "discourse/components/d-page-subheader";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AdminConfigAreaEmptyList from "admin/components/admin-config-area-empty-list";
|
||||
|
||||
export default class AiToolListEditor extends Component {
|
||||
@ -60,7 +59,7 @@ export default class AiToolListEditor extends Component {
|
||||
@route="adminPlugins.show.discourse-ai-tools.edit"
|
||||
@model={{tool}}
|
||||
class="btn btn-text btn-small"
|
||||
>{{I18n.t "discourse_ai.tools.edit"}}</LinkTo>
|
||||
>{{i18n "discourse_ai.tools.edit"}}</LinkTo>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
@ -5,7 +5,7 @@ import { action } from "@ember/object";
|
||||
import { TrackedArray, TrackedObject } from "@ember-compat/tracked-built-ins";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
|
||||
const PARAMETER_TYPES = [
|
||||
@ -76,7 +76,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
{{on "input" (withEventValue (fn (mut parameter.name)))}}
|
||||
value={{parameter.name}}
|
||||
type="text"
|
||||
placeholder={{I18n.t "discourse_ai.tools.parameter_name"}}
|
||||
placeholder={{i18n "discourse_ai.tools.parameter_name"}}
|
||||
/>
|
||||
<ComboBox @value={{parameter.type}} @content={{PARAMETER_TYPES}} />
|
||||
</div>
|
||||
@ -86,7 +86,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
{{on "input" (withEventValue (fn (mut parameter.description)))}}
|
||||
value={{parameter.description}}
|
||||
type="text"
|
||||
placeholder={{I18n.t "discourse_ai.tools.parameter_description"}}
|
||||
placeholder={{i18n "discourse_ai.tools.parameter_description"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -98,7 +98,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
type="checkbox"
|
||||
class="parameter-row__required-toggle"
|
||||
/>
|
||||
{{I18n.t "discourse_ai.tools.parameter_required"}}
|
||||
{{i18n "discourse_ai.tools.parameter_required"}}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
@ -108,7 +108,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
type="checkbox"
|
||||
class="parameter-row__enum-toggle"
|
||||
/>
|
||||
{{I18n.t "discourse_ai.tools.parameter_enum"}}
|
||||
{{i18n "discourse_ai.tools.parameter_enum"}}
|
||||
</label>
|
||||
|
||||
<DButton
|
||||
@ -126,7 +126,7 @@ export default class AiToolParameterEditor extends Component {
|
||||
{{on "change" (fn this.updateEnumValue parameter enumIndex)}}
|
||||
value={{enumValue}}
|
||||
type="text"
|
||||
placeholder={{I18n.t "discourse_ai.tools.enum_value"}}
|
||||
placeholder={{i18n "discourse_ai.tools.enum_value"}}
|
||||
/>
|
||||
<DButton
|
||||
@action={{fn this.removeEnumValue parameter enumIndex}}
|
||||
|
@ -0,0 +1,144 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { tracked } from "@glimmer/tracking";
|
||||
import { hash } from "@ember/helper";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import { not } from "truth-helpers";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DModal from "discourse/components/d-modal";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import GroupChooser from "select-kit/components/group-chooser";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
import DurationSelector from "../ai-quota-duration-selector";
|
||||
|
||||
export default class AiLlmQuotaModal extends Component {
|
||||
@service site;
|
||||
|
||||
@tracked groupIds = null;
|
||||
@tracked maxTokens = null;
|
||||
@tracked maxUsages = null;
|
||||
@tracked duration = 86400; // Default 1 day
|
||||
|
||||
get canSave() {
|
||||
return (
|
||||
this.groupIds?.length > 0 &&
|
||||
(this.maxTokens || this.maxUsages) &&
|
||||
this.duration
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
updateGroups(groups) {
|
||||
this.groupIds = groups;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDuration(value) {
|
||||
this.duration = value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateMaxTokens(event) {
|
||||
this.maxTokens = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateMaxUsages(event) {
|
||||
this.maxUsages = event.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
save() {
|
||||
const quota = {
|
||||
group_id: this.groupIds[0],
|
||||
group_name: this.site.groups.findBy("id", this.groupIds[0]).name,
|
||||
llm_model_id: this.args.model.id,
|
||||
max_tokens: this.maxTokens,
|
||||
max_usages: this.maxUsages,
|
||||
duration_seconds: this.duration,
|
||||
};
|
||||
|
||||
this.args.model.llm.llm_quotas.pushObject(quota);
|
||||
this.args.closeModal();
|
||||
if (this.args.model.onSave) {
|
||||
this.args.model.onSave();
|
||||
}
|
||||
}
|
||||
|
||||
get availableGroups() {
|
||||
const existingQuotaGroupIds =
|
||||
this.args.model.llm.llm_quotas.map((q) => q.group_id) || [];
|
||||
|
||||
return this.site.groups.filter(
|
||||
(group) => !existingQuotaGroupIds.includes(group.id) && group.id !== 0
|
||||
);
|
||||
}
|
||||
|
||||
<template>
|
||||
<DModal
|
||||
@title={{i18n "discourse_ai.llms.quotas.add_title"}}
|
||||
@closeModal={{@closeModal}}
|
||||
class="ai-llm-quota-modal"
|
||||
>
|
||||
<:body>
|
||||
<div class="control-group">
|
||||
<label>{{i18n "discourse_ai.llms.quotas.group"}}</label>
|
||||
<GroupChooser
|
||||
@value={{this.groupIds}}
|
||||
@content={{this.availableGroups}}
|
||||
@onChange={{this.updateGroups}}
|
||||
@options={{hash maximum=1}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>{{i18n "discourse_ai.llms.quotas.max_tokens"}}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={{this.maxTokens}}
|
||||
class="input-large"
|
||||
min="1"
|
||||
{{on "input" this.updateMaxTokens}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{i18n "discourse_ai.llms.quotas.max_tokens_help"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>{{i18n "discourse_ai.llms.quotas.max_usages"}}</label>
|
||||
<input
|
||||
type="number"
|
||||
value={{this.maxUsages}}
|
||||
class="input-large"
|
||||
min="1"
|
||||
{{on "input" this.updateMaxUsages}}
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{i18n "discourse_ai.llms.quotas.max_usages_help"}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<label>{{i18n "discourse_ai.llms.quotas.duration"}}</label>
|
||||
<DurationSelector
|
||||
@value={{this.duration}}
|
||||
@onChange={{this.updateDuration}}
|
||||
/>
|
||||
</div>
|
||||
</:body>
|
||||
|
||||
<:footer>
|
||||
<DButton
|
||||
@action={{this.save}}
|
||||
@label="discourse_ai.llms.quotas.add"
|
||||
@disabled={{not this.canSave}}
|
||||
class="btn-primary"
|
||||
/>
|
||||
</:footer>
|
||||
</DModal>
|
||||
</template>
|
||||
}
|
@ -16,9 +16,8 @@ import htmlClass from "discourse/helpers/html-class";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { shortDateNoYear } from "discourse/lib/formatter";
|
||||
import dIcon from "discourse-common/helpers/d-icon";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import DTooltip from "float-kit/components/d-tooltip";
|
||||
import AiSummarySkeleton from "../../components/ai-summary-skeleton";
|
||||
|
||||
@ -45,11 +44,11 @@ export default class AiSummaryModal extends Component {
|
||||
streamedTextLength = 0;
|
||||
|
||||
get outdatedSummaryWarningText() {
|
||||
let outdatedText = I18n.t("summary.outdated");
|
||||
let outdatedText = i18n("summary.outdated");
|
||||
|
||||
if (!this.topRepliesSummaryEnabled && this.newPostsSinceSummary > 0) {
|
||||
outdatedText += " ";
|
||||
outdatedText += I18n.t("summary.outdated_posts", {
|
||||
outdatedText += i18n("summary.outdated_posts", {
|
||||
count: this.newPostsSinceSummary,
|
||||
});
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import DButton from "discourse/components/d-button";
|
||||
import DModal from "discourse/components/d-modal";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { jsonToHtml } from "../../lib/utilities";
|
||||
|
||||
export default class AiToolTestModal extends Component {
|
||||
@ -45,7 +45,7 @@ export default class AiToolTestModal extends Component {
|
||||
|
||||
<template>
|
||||
<DModal
|
||||
@title={{I18n.t "discourse_ai.tools.test_modal.title"}}
|
||||
@title={{i18n "discourse_ai.tools.test_modal.title"}}
|
||||
@closeModal={{@closeModal}}
|
||||
@bodyClass="ai-tool-test-modal__body"
|
||||
class="ai-tool-test-modal"
|
||||
@ -64,7 +64,7 @@ export default class AiToolTestModal extends Component {
|
||||
|
||||
{{#if this.testResult}}
|
||||
<div class="ai-tool-test-modal__test-result">
|
||||
<h3>{{I18n.t "discourse_ai.tools.test_modal.result"}}</h3>
|
||||
<h3>{{i18n "discourse_ai.tools.test_modal.result"}}</h3>
|
||||
<div>{{this.testResult}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
@ -7,8 +7,7 @@ import DModal from "discourse/components/d-modal";
|
||||
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-common/helpers/i18n";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ComboBox from "select-kit/components/combo-box";
|
||||
|
||||
export default class ChatModalChannelSummary extends Component {
|
||||
@ -22,7 +21,7 @@ export default class ChatModalChannelSummary extends Component {
|
||||
|
||||
sinceOptions = [1, 3, 6, 12, 24, 72, 168].map((hours) => {
|
||||
return {
|
||||
name: I18n.t("discourse_ai.summarization.chat.since", { count: hours }),
|
||||
name: i18n("discourse_ai.summarization.chat.since", { count: hours }),
|
||||
value: hours,
|
||||
};
|
||||
});
|
||||
|
@ -9,9 +9,8 @@ import DModal from "discourse/components/d-modal";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { clipboardCopy, escapeExpression } from "discourse/lib/utilities";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { jsonToHtml } from "../../lib/utilities";
|
||||
|
||||
export default class DebugAiModal extends Component {
|
||||
@ -88,7 +87,7 @@ export default class DebugAiModal extends Component {
|
||||
|
||||
copy(text) {
|
||||
clipboardCopy(text);
|
||||
this.justCopiedText = I18n.t("discourse_ai.ai_bot.conversation_shared");
|
||||
this.justCopiedText = i18n("discourse_ai.ai_bot.conversation_shared");
|
||||
|
||||
discourseLater(() => {
|
||||
this.justCopiedText = "";
|
||||
|
@ -8,9 +8,8 @@ import DModal from "discourse/components/d-modal";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import { clipboardCopyAsync } from "discourse/lib/utilities";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import { getAbsoluteURL } from "discourse-common/lib/get-url";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class ShareModal extends Component {
|
||||
@service toasts;
|
||||
@ -83,7 +82,7 @@ export default class ShareModal extends Component {
|
||||
this.toasts.success({
|
||||
duration: 3000,
|
||||
data: {
|
||||
message: I18n.t("discourse_ai.ai_bot.conversation_shared"),
|
||||
message: i18n("discourse_ai.ai_bot.conversation_shared"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -7,9 +7,8 @@ import { service } from "@ember/service";
|
||||
import { htmlSafe } from "@ember/template";
|
||||
import DButton from "discourse/components/d-button";
|
||||
import DModal from "discourse/components/d-modal";
|
||||
import i18n from "discourse-common/helpers/i18n";
|
||||
import discourseLater from "discourse-common/lib/later";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import { showShareConversationModal } from "../../lib/ai-bot-helper";
|
||||
import copyConversation from "../../lib/copy-conversation";
|
||||
|
||||
@ -69,7 +68,7 @@ export default class ShareModal extends Component {
|
||||
const to = this.args.model.post_number;
|
||||
|
||||
await copyConversation(this.args.model.topic, from, to);
|
||||
this.justCopiedText = I18n.t("discourse_ai.ai_bot.conversation_shared");
|
||||
this.justCopiedText = i18n("discourse_ai.ai_bot.conversation_shared");
|
||||
|
||||
discourseLater(() => {
|
||||
this.justCopiedText = "";
|
||||
|
@ -8,7 +8,7 @@ import DModal from "discourse/components/d-modal";
|
||||
import withEventValue from "discourse/helpers/with-event-value";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import AiIndicatorWave from "../ai-indicator-wave";
|
||||
|
||||
export default class SpamTestModal extends Component {
|
||||
@ -36,8 +36,8 @@ export default class SpamTestModal extends Component {
|
||||
|
||||
this.isSpam = response.is_spam;
|
||||
this.testResult = response.is_spam
|
||||
? I18n.t("discourse_ai.spam.test_modal.spam")
|
||||
: I18n.t("discourse_ai.spam.test_modal.not_spam");
|
||||
? i18n("discourse_ai.spam.test_modal.spam")
|
||||
: i18n("discourse_ai.spam.test_modal.not_spam");
|
||||
this.scanLog = response.log;
|
||||
} catch (error) {
|
||||
popupAjaxError(error);
|
||||
@ -48,20 +48,18 @@ export default class SpamTestModal extends Component {
|
||||
|
||||
<template>
|
||||
<DModal
|
||||
@title={{I18n.t "discourse_ai.spam.test_modal.title"}}
|
||||
@title={{i18n "discourse_ai.spam.test_modal.title"}}
|
||||
@closeModal={{@closeModal}}
|
||||
@bodyClass="spam-test-modal__body"
|
||||
class="spam-test-modal"
|
||||
>
|
||||
<:body>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t
|
||||
"discourse_ai.spam.test_modal.post_url_label"
|
||||
}}</label>
|
||||
<label>{{i18n "discourse_ai.spam.test_modal.post_url_label"}}</label>
|
||||
<input
|
||||
{{on "input" (withEventValue (fn (mut this.postUrl)))}}
|
||||
type="text"
|
||||
placeholder={{I18n.t
|
||||
placeholder={{i18n
|
||||
"discourse_ai.spam.test_modal.post_url_placeholder"
|
||||
}}
|
||||
/>
|
||||
@ -69,7 +67,7 @@ export default class SpamTestModal extends Component {
|
||||
|
||||
{{#if this.testResult}}
|
||||
<div class="spam-test-modal__test-result">
|
||||
<h3>{{I18n.t "discourse_ai.spam.test_modal.result"}}</h3>
|
||||
<h3>{{i18n "discourse_ai.spam.test_modal.result"}}</h3>
|
||||
<div
|
||||
class="spam-test-modal__verdict
|
||||
{{if this.isSpam 'is-spam' 'not-spam'}}"
|
||||
@ -78,7 +76,7 @@ export default class SpamTestModal extends Component {
|
||||
</div>
|
||||
{{#if this.scanLog}}
|
||||
<div class="spam-test-modal__log">
|
||||
<h4>{{I18n.t "discourse_ai.spam.test_modal.scan_log"}}</h4>
|
||||
<h4>{{i18n "discourse_ai.spam.test_modal.scan_log"}}</h4>
|
||||
<pre>{{this.scanLog}}</pre>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
@ -4,7 +4,7 @@ import { Input } from "@ember/component";
|
||||
import { on } from "@ember/modifier";
|
||||
import { action } from "@ember/object";
|
||||
import DTooltip from "discourse/components/d-tooltip";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class RagOptions extends Component {
|
||||
@tracked showIndexingOptions = false;
|
||||
@ -18,8 +18,8 @@ export default class RagOptions extends Component {
|
||||
|
||||
get indexingOptionsText() {
|
||||
return this.showIndexingOptions
|
||||
? I18n.t("discourse_ai.rag.options.hide_indexing_options")
|
||||
: I18n.t("discourse_ai.rag.options.show_indexing_options");
|
||||
? i18n("discourse_ai.rag.options.hide_indexing_options")
|
||||
: i18n("discourse_ai.rag.options.show_indexing_options");
|
||||
}
|
||||
|
||||
<template>
|
||||
@ -33,7 +33,7 @@ export default class RagOptions extends Component {
|
||||
|
||||
{{#if this.showIndexingOptions}}
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t "discourse_ai.rag.options.rag_chunk_tokens"}}</label>
|
||||
<label>{{i18n "discourse_ai.rag.options.rag_chunk_tokens"}}</label>
|
||||
<Input
|
||||
@type="number"
|
||||
step="any"
|
||||
@ -43,11 +43,11 @@ export default class RagOptions extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t "discourse_ai.rag.options.rag_chunk_tokens_help"}}
|
||||
@content={{i18n "discourse_ai.rag.options.rag_chunk_tokens_help"}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<label>{{I18n.t
|
||||
<label>{{i18n
|
||||
"discourse_ai.rag.options.rag_chunk_overlap_tokens"
|
||||
}}</label>
|
||||
<Input
|
||||
@ -59,7 +59,7 @@ export default class RagOptions extends Component {
|
||||
/>
|
||||
<DTooltip
|
||||
@icon="circle-question"
|
||||
@content={{I18n.t
|
||||
@content={{i18n
|
||||
"discourse_ai.rag.options.rag_chunk_overlap_tokens_help"
|
||||
}}
|
||||
/>
|
||||
|
@ -5,7 +5,7 @@ import didInsert from "@ember/render-modifiers/modifiers/did-insert";
|
||||
import { service } from "@ember/service";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import { bind } from "discourse-common/utils/decorators";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class RagUploadProgress extends Component {
|
||||
@service messageBus;
|
||||
@ -67,17 +67,17 @@ export default class RagUploadProgress extends Component {
|
||||
{{#if this.fullyIndexed}}
|
||||
<span class="indexed">
|
||||
{{icon "check"}}
|
||||
{{I18n.t "discourse_ai.rag.uploads.indexed"}}
|
||||
{{i18n "discourse_ai.rag.uploads.indexed"}}
|
||||
</span>
|
||||
{{else}}
|
||||
<span class="indexing">
|
||||
{{icon "robot"}}
|
||||
{{I18n.t "discourse_ai.rag.uploads.indexing"}}
|
||||
{{i18n "discourse_ai.rag.uploads.indexing"}}
|
||||
{{this.calculateProgress}}%
|
||||
</span>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<span class="uploaded">{{I18n.t
|
||||
<span class="uploaded">{{i18n
|
||||
"discourse_ai.rag.uploads.uploaded"
|
||||
}}</span>
|
||||
{{/if}}
|
||||
|
@ -11,7 +11,7 @@ import { ajax } from "discourse/lib/ajax";
|
||||
import UppyUpload from "discourse/lib/uppy/uppy-upload";
|
||||
import icon from "discourse-common/helpers/d-icon";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import RagUploadProgress from "./rag-upload-progress";
|
||||
|
||||
export default class RagUploader extends Component {
|
||||
@ -31,7 +31,7 @@ export default class RagUploader extends Component {
|
||||
uploadDone: (uploadedFile) => {
|
||||
const newUpload = uploadedFile.upload;
|
||||
newUpload.status = "uploaded";
|
||||
newUpload.statusText = I18n.t("discourse_ai.rag.uploads.uploaded");
|
||||
newUpload.statusText = i18n("discourse_ai.rag.uploads.uploaded");
|
||||
this.ragUploads.pushObject(newUpload);
|
||||
this.debouncedSearch();
|
||||
},
|
||||
@ -118,8 +118,8 @@ export default class RagUploader extends Component {
|
||||
|
||||
<template>
|
||||
<div class="rag-uploader">
|
||||
<h3>{{I18n.t "discourse_ai.rag.uploads.title"}}</h3>
|
||||
<p>{{I18n.t "discourse_ai.rag.uploads.description"}}</p>
|
||||
<h3>{{i18n "discourse_ai.rag.uploads.title"}}</h3>
|
||||
<p>{{i18n "discourse_ai.rag.uploads.description"}}</p>
|
||||
|
||||
{{#if this.ragUploads}}
|
||||
<div class="rag-uploader__search-input-container">
|
||||
@ -127,7 +127,7 @@ export default class RagUploader extends Component {
|
||||
{{icon "search" class="rag-uploader__search-input__search-icon"}}
|
||||
<Input
|
||||
class="rag-uploader__search-input__input"
|
||||
placeholder={{I18n.t "discourse_ai.rag.uploads.filter"}}
|
||||
placeholder={{i18n "discourse_ai.rag.uploads.filter"}}
|
||||
@value={{this.term}}
|
||||
{{on "keyup" this.debouncedSearch}}
|
||||
/>
|
||||
@ -165,7 +165,7 @@ export default class RagUploader extends Component {
|
||||
{{upload.original_filename}}</td>
|
||||
<td class="rag-uploader__upload-status">
|
||||
<div class="spinner small"></div>
|
||||
<span>{{I18n.t "discourse_ai.rag.uploads.uploading"}}
|
||||
<span>{{i18n "discourse_ai.rag.uploads.uploading"}}
|
||||
{{upload.uploadProgress}}%</span>
|
||||
</td>
|
||||
<td class="rag-uploader__remove-file">
|
||||
|
@ -1,7 +1,7 @@
|
||||
import Component from "@glimmer/component";
|
||||
import { computed } from "@ember/object";
|
||||
import { service } from "@ember/service";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default class extends Component {
|
||||
@service currentUser;
|
||||
@ -19,7 +19,7 @@ export default class extends Component {
|
||||
get aiBotClasses() {
|
||||
if (
|
||||
this.composerModel?.title ===
|
||||
I18n.t("discourse_ai.ai_bot.default_pm_prefix")
|
||||
i18n("discourse_ai.ai_bot.default_pm_prefix")
|
||||
) {
|
||||
return "ai-bot-chat";
|
||||
} else {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { popupAjaxError } from "discourse/lib/ajax-error";
|
||||
import Composer from "discourse/models/composer";
|
||||
import I18n from "I18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
import ShareFullTopicModal from "../components/modal/share-full-topic-modal";
|
||||
|
||||
const MAX_PERSONA_USER_ID = -1200;
|
||||
@ -35,7 +35,7 @@ export function composeAiBotMessage(targetBot, composer) {
|
||||
openOpts: {
|
||||
action: Composer.PRIVATE_MESSAGE,
|
||||
recipients: botUsername,
|
||||
topicTitle: I18n.t("discourse_ai.ai_bot.default_pm_prefix"),
|
||||
topicTitle: i18n("discourse_ai.ai_bot.default_pm_prefix"),
|
||||
archetypeId: "private_message",
|
||||
draftKey: "private_message_ai",
|
||||
hasGroups: false,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { clipboardCopyAsync } from "discourse/lib/utilities";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default async function (topic, fromPostNumber, toPostNumber) {
|
||||
await clipboardCopyAsync(async () => {
|
||||
@ -39,7 +39,7 @@ async function generateClipboard(topic, fromPostNumber, toPostNumber) {
|
||||
buffer.push("<summary>");
|
||||
buffer.push(`<span>${topic.title}</span>`);
|
||||
buffer.push(
|
||||
`<span title='${I18n.t("discourse_ai.ai_bot.ai_title")}'>${I18n.t(
|
||||
`<span title='${i18n("discourse_ai.ai_bot.ai_title")}'>${i18n(
|
||||
"discourse_ai.ai_bot.ai_label"
|
||||
)}</span>`
|
||||
);
|
||||
|
@ -6,11 +6,11 @@ import {
|
||||
IMAGE_MARKDOWN_REGEX,
|
||||
isImage,
|
||||
} from "discourse/lib/uploads";
|
||||
import I18n from "discourse-i18n";
|
||||
import { i18n } from "discourse-i18n";
|
||||
|
||||
export default apiInitializer("1.25.0", (api) => {
|
||||
const buttonAttrs = {
|
||||
label: I18n.t("discourse_ai.ai_helper.image_caption.button_label"),
|
||||
label: i18n("discourse_ai.ai_helper.image_caption.button_label"),
|
||||
icon: "discourse-sparkles",
|
||||
class: "generate-caption",
|
||||
};
|
||||
@ -206,7 +206,7 @@ export default apiInitializer("1.25.0", (api) => {
|
||||
keyValueStore.setItem(autoCaptionPromptKey, true);
|
||||
|
||||
dialog.confirm({
|
||||
message: I18n.t(`${localePrefix}.prompt`),
|
||||
message: i18n(`${localePrefix}.prompt`),
|
||||
confirmButtonLabel: `${localePrefix}.confirm`,
|
||||
cancelButtonLabel: `${localePrefix}.cancel`,
|
||||
class: "ai-image-caption-prompt-dialog",
|
||||
|
71
assets/stylesheets/modules/llms/common/ai-llm-quotas.scss
Normal file
71
assets/stylesheets/modules/llms/common/ai-llm-quotas.scss
Normal file
@ -0,0 +1,71 @@
|
||||
.ai-llm-quotas {
|
||||
margin: 1em 0;
|
||||
|
||||
&__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
&__table-head {
|
||||
background-color: var(--primary-very-low);
|
||||
}
|
||||
|
||||
.duration-selector {
|
||||
.select-kit {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.duration-selector__custom {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
text-align: left;
|
||||
padding: 0.5em;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid var(--primary-low);
|
||||
|
||||
&--actions {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
border-bottom: 1px solid var(--primary-low);
|
||||
}
|
||||
|
||||
&__cell {
|
||||
vertical-align: middle;
|
||||
align-items: center;
|
||||
|
||||
&--actions {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__input[type="number"] {
|
||||
width: 200px;
|
||||
padding: 0.5em;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&__group-select {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
&__delete-btn {
|
||||
padding: 0.3em 0.5em;
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
padding: 0.3em 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-llm-quota-modal {
|
||||
.fk-d-tooltip__icon {
|
||||
color: var(--primary-medium);
|
||||
}
|
||||
}
|
@ -342,6 +342,24 @@ en:
|
||||
confirm_delete: Are you sure you want to delete this model?
|
||||
delete: Delete
|
||||
seeded_warning: "This model is pre-configured on your site and cannot be edited."
|
||||
quotas:
|
||||
title: "Usage quotas"
|
||||
add_title: "Create new quota"
|
||||
group: "Group"
|
||||
max_tokens: "Max tokens"
|
||||
max_usages: "Max uses"
|
||||
duration: "Duration"
|
||||
confirm_delete: "Are you sure you want to delete this quota?"
|
||||
add: "Add quota"
|
||||
durations:
|
||||
hour: "1 hour"
|
||||
six_hours: "6 hours"
|
||||
day: "24 hours"
|
||||
week: "7 days"
|
||||
custom: "Custom..."
|
||||
hours: "hours"
|
||||
max_tokens_help: "Maximum number of tokens (words and characters) that each user in this group can use within the specified duration. Tokens are the units used by AI models to process text - roughly 1 token = 4 characters or 3/4 of a word."
|
||||
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."
|
||||
usage:
|
||||
ai_bot: "AI bot"
|
||||
ai_helper: "Helper"
|
||||
|
@ -450,6 +450,8 @@ en:
|
||||
bedrock_invalid_url: "Please complete all the fields to use this model."
|
||||
|
||||
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"
|
||||
no_query_specified: The query parameter is required, please specify it.
|
||||
no_user_for_persona: The persona specified does not have a user associated with it.
|
||||
persona_not_found: The persona specified does not exist. Check the persona_name or persona_id params.
|
||||
|
@ -91,6 +91,11 @@ Discourse::Application.routes.draw do
|
||||
controller: "discourse_ai/admin/ai_llms" do
|
||||
collection { get :test }
|
||||
end
|
||||
|
||||
resources :ai_llm_quotas,
|
||||
controller: "discourse_ai/admin/ai_llm_quotas",
|
||||
path: "quotas",
|
||||
only: %i[index create update destroy]
|
||||
end
|
||||
end
|
||||
|
||||
|
31
db/migrate/20250102035341_add_llm_quota_tables.rb
Normal file
31
db/migrate/20250102035341_add_llm_quota_tables.rb
Normal file
@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddLlmQuotaTables < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :llm_quotas do |t|
|
||||
t.bigint :group_id, null: false
|
||||
t.bigint :llm_model_id, null: false
|
||||
t.integer :max_tokens
|
||||
t.integer :max_usages
|
||||
t.integer :duration_seconds, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :llm_quotas, :llm_model_id
|
||||
add_index :llm_quotas, %i[group_id llm_model_id], unique: true
|
||||
|
||||
create_table :llm_quota_usages do |t|
|
||||
t.bigint :user_id, null: false
|
||||
t.bigint :llm_quota_id, null: false
|
||||
t.integer :input_tokens_used, null: false
|
||||
t.integer :output_tokens_used, null: false
|
||||
t.integer :usages, null: false
|
||||
t.datetime :started_at, null: false
|
||||
t.datetime :reset_at, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :llm_quota_usages, :llm_quota_id
|
||||
add_index :llm_quota_usages, %i[user_id llm_quota_id], unique: true
|
||||
end
|
||||
end
|
@ -65,6 +65,8 @@ module DiscourseAi
|
||||
partial_tool_calls: false,
|
||||
&blk
|
||||
)
|
||||
LlmQuota.check_quotas!(@llm_model, user)
|
||||
|
||||
@partial_tool_calls = partial_tool_calls
|
||||
model_params = normalize_model_params(model_params)
|
||||
orig_blk = blk
|
||||
@ -209,10 +211,9 @@ module DiscourseAi
|
||||
if log
|
||||
log.raw_response_payload = response_raw
|
||||
final_log_update(log)
|
||||
|
||||
log.response_tokens = tokenizer.size(partials_raw) if log.response_tokens.blank?
|
||||
log.save!
|
||||
|
||||
LlmQuota.log_usage(@llm_model, user, log.request_tokens, log.response_tokens)
|
||||
if Rails.env.development?
|
||||
puts "#{self.class.name}: request_tokens #{log.request_tokens} response_tokens #{log.response_tokens}"
|
||||
end
|
||||
|
@ -38,6 +38,7 @@ register_asset "stylesheets/modules/llms/common/ai-llms-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/ai-bot/common/ai-tools.scss"
|
||||
|
||||
|
9
spec/fabricators/llm_quota_fabricator.rb
Normal file
9
spec/fabricators/llm_quota_fabricator.rb
Normal file
@ -0,0 +1,9 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:llm_quota) do
|
||||
group
|
||||
llm_model
|
||||
max_tokens { 1000 }
|
||||
max_usages { 10 }
|
||||
duration_seconds { 1.day.to_i }
|
||||
end
|
11
spec/fabricators/llm_quota_usage_fabricator.rb
Normal file
11
spec/fabricators/llm_quota_usage_fabricator.rb
Normal file
@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
Fabricator(:llm_quota_usage) do
|
||||
user
|
||||
llm_quota
|
||||
input_tokens_used { 0 }
|
||||
output_tokens_used { 0 }
|
||||
usages { 0 }
|
||||
started_at { Time.current }
|
||||
reset_at { Time.current + 1.day }
|
||||
end
|
129
spec/models/llm_quota_spec.rb
Normal file
129
spec/models/llm_quota_spec.rb
Normal file
@ -0,0 +1,129 @@
|
||||
# frozen_string_literal: true
|
||||
RSpec.describe LlmQuota do
|
||||
fab!(:group)
|
||||
fab!(:user)
|
||||
fab!(:llm_model)
|
||||
|
||||
before { group.add(user) }
|
||||
|
||||
describe ".check_quotas!" do
|
||||
it "returns true when user is nil" do
|
||||
expect(described_class.check_quotas!(llm_model, nil)).to be true
|
||||
end
|
||||
|
||||
it "returns true when no quotas exist for the user's groups" do
|
||||
expect(described_class.check_quotas!(llm_model, user)).to be true
|
||||
end
|
||||
|
||||
it "raises no error when within quota" do
|
||||
quota = Fabricate(:llm_quota, group: group, llm_model: llm_model)
|
||||
_usage =
|
||||
Fabricate(
|
||||
:llm_quota_usage,
|
||||
user: user,
|
||||
llm_quota: quota,
|
||||
input_tokens_used: quota.max_tokens - 100,
|
||||
)
|
||||
|
||||
expect { described_class.check_quotas!(llm_model, user) }.not_to raise_error
|
||||
end
|
||||
|
||||
it "raises error when usage exceeds token limit" do
|
||||
quota = Fabricate(:llm_quota, group: group, llm_model: llm_model, max_tokens: 1000)
|
||||
_usage = Fabricate(:llm_quota_usage, user: user, llm_quota: quota, input_tokens_used: 1100)
|
||||
|
||||
expect { described_class.check_quotas!(llm_model, user) }.to raise_error(
|
||||
LlmQuotaUsage::QuotaExceededError,
|
||||
)
|
||||
end
|
||||
|
||||
it "raises error when usage exceeds usage limit" do
|
||||
quota = Fabricate(:llm_quota, group: group, llm_model: llm_model, max_usages: 10)
|
||||
_usage = Fabricate(:llm_quota_usage, user: user, llm_quota: quota, usages: 11)
|
||||
|
||||
expect { described_class.check_quotas!(llm_model, user) }.to raise_error(
|
||||
LlmQuotaUsage::QuotaExceededError,
|
||||
)
|
||||
end
|
||||
|
||||
it "checks all quotas from user's groups" do
|
||||
group2 = Fabricate(:group)
|
||||
group2.add(user)
|
||||
|
||||
quota1 = Fabricate(:llm_quota, group: group, llm_model: llm_model, max_tokens: 1000)
|
||||
quota2 = Fabricate(:llm_quota, group: group2, llm_model: llm_model, max_tokens: 500)
|
||||
|
||||
described_class.log_usage(llm_model, user, 900, 0) # Should create usages for both quotas
|
||||
|
||||
expect { described_class.check_quotas!(llm_model, user) }.not_to raise_error
|
||||
|
||||
described_class.log_usage(llm_model, user, 101, 0) # This should push quota2 over its limit
|
||||
|
||||
expect { described_class.check_quotas!(llm_model, user) }.to raise_error(
|
||||
LlmQuotaUsage::QuotaExceededError,
|
||||
)
|
||||
|
||||
# Verify the usage was logged for both quotas
|
||||
expect(LlmQuotaUsage.find_by(llm_quota: quota1).total_tokens_used).to eq(1001)
|
||||
expect(LlmQuotaUsage.find_by(llm_quota: quota2).total_tokens_used).to eq(1001)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".log_usage" do
|
||||
it "does nothing when user is nil" do
|
||||
expect { described_class.log_usage(llm_model, nil, 100, 50) }.not_to change(
|
||||
LlmQuotaUsage,
|
||||
:count,
|
||||
)
|
||||
end
|
||||
|
||||
it "creates usage records when none exist" do
|
||||
_quota = Fabricate(:llm_quota, group: group, llm_model: llm_model)
|
||||
|
||||
expect { described_class.log_usage(llm_model, user, 100, 50) }.to change(
|
||||
LlmQuotaUsage,
|
||||
:count,
|
||||
).by(1)
|
||||
|
||||
usage = LlmQuotaUsage.last
|
||||
expect(usage.input_tokens_used).to eq(100)
|
||||
expect(usage.output_tokens_used).to eq(50)
|
||||
expect(usage.usages).to eq(1)
|
||||
end
|
||||
|
||||
it "updates existing usage records" do
|
||||
quota = Fabricate(:llm_quota, group: group, llm_model: llm_model)
|
||||
usage =
|
||||
Fabricate(
|
||||
:llm_quota_usage,
|
||||
user: user,
|
||||
llm_quota: quota,
|
||||
input_tokens_used: 100,
|
||||
output_tokens_used: 50,
|
||||
usages: 1,
|
||||
)
|
||||
|
||||
described_class.log_usage(llm_model, user, 50, 25)
|
||||
|
||||
usage.reload
|
||||
expect(usage.input_tokens_used).to eq(150)
|
||||
expect(usage.output_tokens_used).to eq(75)
|
||||
expect(usage.usages).to eq(2)
|
||||
end
|
||||
|
||||
it "logs usage for all quotas from user's groups" do
|
||||
group2 = Fabricate(:group)
|
||||
group2.add(user)
|
||||
|
||||
_quota1 = Fabricate(:llm_quota, group: group, llm_model: llm_model)
|
||||
_quota2 = Fabricate(:llm_quota, group: group2, llm_model: llm_model)
|
||||
|
||||
expect { described_class.log_usage(llm_model, user, 100, 50) }.to change(
|
||||
LlmQuotaUsage,
|
||||
:count,
|
||||
).by(2)
|
||||
|
||||
expect(LlmQuotaUsage.where(user: user).count).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
250
spec/models/llm_quota_usage_spec.rb
Normal file
250
spec/models/llm_quota_usage_spec.rb
Normal file
@ -0,0 +1,250 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe LlmQuotaUsage do
|
||||
fab!(:group)
|
||||
fab!(:user)
|
||||
fab!(:llm_model)
|
||||
|
||||
fab!(:llm_quota) do
|
||||
Fabricate(
|
||||
:llm_quota,
|
||||
group: group,
|
||||
llm_model: llm_model,
|
||||
max_tokens: 1000,
|
||||
max_usages: 10,
|
||||
duration_seconds: 1.day.to_i,
|
||||
)
|
||||
end
|
||||
|
||||
describe ".find_or_create_for" do
|
||||
it "creates a new usage record if none exists" do
|
||||
freeze_time
|
||||
|
||||
usage = described_class.find_or_create_for(user: user, llm_quota: llm_quota)
|
||||
|
||||
expect(usage).to be_persisted
|
||||
expect(usage.started_at).to eq_time(Time.current)
|
||||
expect(usage.reset_at).to eq_time(Time.current + llm_quota.duration_seconds.seconds)
|
||||
expect(usage.input_tokens_used).to eq(0)
|
||||
expect(usage.output_tokens_used).to eq(0)
|
||||
expect(usage.usages).to eq(0)
|
||||
end
|
||||
|
||||
it "returns existing usage record if one exists" do
|
||||
existing = Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota)
|
||||
|
||||
usage = described_class.find_or_create_for(user: user, llm_quota: llm_quota)
|
||||
|
||||
expect(usage.id).to eq(existing.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#reset_if_needed!" do
|
||||
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
|
||||
|
||||
it "resets usage when past reset_at" do
|
||||
usage.update!(
|
||||
input_tokens_used: 100,
|
||||
output_tokens_used: 200,
|
||||
usages: 5,
|
||||
reset_at: 1.minute.ago,
|
||||
)
|
||||
|
||||
freeze_time
|
||||
|
||||
usage.reset_if_needed!
|
||||
|
||||
expect(usage.reload.input_tokens_used).to eq(0)
|
||||
expect(usage.output_tokens_used).to eq(0)
|
||||
expect(usage.usages).to eq(0)
|
||||
expect(usage.started_at).to eq_time(Time.current)
|
||||
expect(usage.reset_at).to eq_time(Time.current + llm_quota.duration_seconds.seconds)
|
||||
end
|
||||
|
||||
it "doesn't reset if reset_at hasn't passed" do
|
||||
freeze_time
|
||||
|
||||
original_values = {
|
||||
input_tokens_used: 100,
|
||||
output_tokens_used: 200,
|
||||
usages: 5,
|
||||
reset_at: 1.minute.from_now,
|
||||
}
|
||||
|
||||
usage.update!(original_values)
|
||||
usage.reset_if_needed!
|
||||
|
||||
usage.reload
|
||||
expect(usage.input_tokens_used).to eq(original_values[:input_tokens_used])
|
||||
expect(usage.output_tokens_used).to eq(original_values[:output_tokens_used])
|
||||
expect(usage.usages).to eq(original_values[:usages])
|
||||
expect(usage.reset_at).to eq_time(original_values[:reset_at])
|
||||
end
|
||||
end
|
||||
|
||||
describe "#increment_usage!" do
|
||||
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
|
||||
|
||||
it "increments usage counts" do
|
||||
usage.increment_usage!(input_tokens: 50, output_tokens: 30)
|
||||
|
||||
expect(usage.reload.input_tokens_used).to eq(50)
|
||||
expect(usage.output_tokens_used).to eq(30)
|
||||
expect(usage.usages).to eq(1)
|
||||
end
|
||||
|
||||
it "accumulates multiple increments" do
|
||||
2.times { usage.increment_usage!(input_tokens: 50, output_tokens: 30) }
|
||||
|
||||
expect(usage.reload.input_tokens_used).to eq(100)
|
||||
expect(usage.output_tokens_used).to eq(60)
|
||||
expect(usage.usages).to eq(2)
|
||||
end
|
||||
|
||||
it "resets counts if needed before incrementing" do
|
||||
usage.update!(
|
||||
input_tokens_used: 100,
|
||||
output_tokens_used: 200,
|
||||
usages: 5,
|
||||
reset_at: 1.minute.ago,
|
||||
)
|
||||
|
||||
usage.increment_usage!(input_tokens: 50, output_tokens: 30)
|
||||
|
||||
expect(usage.reload.input_tokens_used).to eq(50)
|
||||
expect(usage.output_tokens_used).to eq(30)
|
||||
expect(usage.usages).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#check_quota!" do
|
||||
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
|
||||
|
||||
it "doesn't raise error when within limits" do
|
||||
expect { usage.check_quota! }.not_to raise_error
|
||||
end
|
||||
|
||||
it "raises error when max_tokens exceeded" do
|
||||
usage.update!(input_tokens_used: llm_quota.max_tokens + 1)
|
||||
|
||||
expect { usage.check_quota! }.to raise_error(LlmQuotaUsage::QuotaExceededError, /exceeded/)
|
||||
end
|
||||
|
||||
it "raises error when max_usages exceeded" do
|
||||
usage.update!(usages: llm_quota.max_usages + 1)
|
||||
|
||||
expect { usage.check_quota! }.to raise_error(LlmQuotaUsage::QuotaExceededError, /exceeded/)
|
||||
end
|
||||
|
||||
it "resets quota if needed before checking" do
|
||||
usage.update!(input_tokens_used: llm_quota.max_tokens + 1, reset_at: 1.minute.ago)
|
||||
|
||||
expect { usage.check_quota! }.not_to raise_error
|
||||
expect(usage.reload.input_tokens_used).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#quota_exceeded?" do
|
||||
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
|
||||
|
||||
it "returns false when within limits" do
|
||||
expect(usage.quota_exceeded?).to be false
|
||||
end
|
||||
|
||||
it "returns true when max_tokens exceeded" do
|
||||
usage.update!(input_tokens_used: llm_quota.max_tokens + 1)
|
||||
expect(usage.quota_exceeded?).to be true
|
||||
end
|
||||
|
||||
it "returns true when max_usages exceeded" do
|
||||
usage.update!(usages: llm_quota.max_usages + 1)
|
||||
expect(usage.quota_exceeded?).to be true
|
||||
end
|
||||
|
||||
it "returns false when quota is nil" do
|
||||
tokens = llm_quota.max_tokens + 1
|
||||
usage.llm_quota.update!(max_tokens: nil)
|
||||
usage.update!(input_tokens_used: tokens)
|
||||
expect(usage.quota_exceeded?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe "calculation methods" do
|
||||
let(:usage) { Fabricate(:llm_quota_usage, user: user, llm_quota: llm_quota) }
|
||||
|
||||
describe "#total_tokens_used" do
|
||||
it "sums input and output tokens" do
|
||||
usage.update!(input_tokens_used: 100, output_tokens_used: 200)
|
||||
expect(usage.total_tokens_used).to eq(300)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#remaining_tokens" do
|
||||
it "calculates remaining tokens when under limit" do
|
||||
usage.update!(input_tokens_used: 300, output_tokens_used: 200)
|
||||
expect(usage.remaining_tokens).to eq(500)
|
||||
end
|
||||
|
||||
it "returns 0 when over limit" do
|
||||
usage.update!(input_tokens_used: 800, output_tokens_used: 300)
|
||||
expect(usage.remaining_tokens).to eq(0)
|
||||
end
|
||||
|
||||
it "returns nil when no max_tokens set" do
|
||||
usage.llm_quota.update!(max_tokens: nil)
|
||||
expect(usage.remaining_tokens).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "#remaining_usages" do
|
||||
it "calculates remaining usages when under limit" do
|
||||
usage.update!(usages: 7)
|
||||
expect(usage.remaining_usages).to eq(3)
|
||||
end
|
||||
|
||||
it "returns 0 when over limit" do
|
||||
usage.update!(usages: 15)
|
||||
expect(usage.remaining_usages).to eq(0)
|
||||
end
|
||||
|
||||
it "returns nil when no max_usages set" do
|
||||
usage.llm_quota.update!(max_usages: nil)
|
||||
expect(usage.remaining_usages).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "#percentage_tokens_used" do
|
||||
it "calculates percentage correctly" do
|
||||
usage.update!(input_tokens_used: 250, output_tokens_used: 250)
|
||||
expect(usage.percentage_tokens_used).to eq(50)
|
||||
end
|
||||
|
||||
it "caps at 100%" do
|
||||
usage.update!(input_tokens_used: 2000)
|
||||
expect(usage.percentage_tokens_used).to eq(100)
|
||||
end
|
||||
|
||||
it "returns 0 when no max_tokens set" do
|
||||
usage.llm_quota.update!(max_tokens: nil)
|
||||
expect(usage.percentage_tokens_used).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#percentage_usages_used" do
|
||||
it "calculates percentage correctly" do
|
||||
usage.update!(usages: 5)
|
||||
expect(usage.percentage_usages_used).to eq(50)
|
||||
end
|
||||
|
||||
it "caps at 100%" do
|
||||
usage.update!(usages: 20)
|
||||
expect(usage.percentage_usages_used).to eq(100)
|
||||
end
|
||||
|
||||
it "returns 0 when no max_usages set" do
|
||||
usage.llm_quota.update!(max_usages: nil)
|
||||
expect(usage.percentage_usages_used).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
108
spec/requests/admin/ai_llm_quotas_controller_spec.rb
Normal file
108
spec/requests/admin/ai_llm_quotas_controller_spec.rb
Normal file
@ -0,0 +1,108 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe DiscourseAi::Admin::AiLlmQuotasController do
|
||||
fab!(:admin)
|
||||
fab!(:group)
|
||||
fab!(:llm_model)
|
||||
|
||||
before do
|
||||
sign_in(admin)
|
||||
SiteSetting.ai_bot_enabled = true
|
||||
SiteSetting.discourse_ai_enabled = true
|
||||
end
|
||||
|
||||
describe "#index" do
|
||||
fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) }
|
||||
fab!(:quota2) { Fabricate(:llm_quota, llm_model: llm_model) }
|
||||
|
||||
it "lists all quotas for a given LLM" do
|
||||
get "/admin/plugins/discourse-ai/quotas.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
quotas = response.parsed_body["quotas"]
|
||||
expect(quotas.length).to eq(2)
|
||||
expect(quotas.map { |q| q["id"] }).to contain_exactly(quota.id, quota2.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#create" do
|
||||
let(:valid_params) do
|
||||
{
|
||||
quota: {
|
||||
group_id: group.id,
|
||||
llm_model_id: llm_model.id,
|
||||
max_tokens: 1000,
|
||||
max_usages: 100,
|
||||
duration_seconds: 1.day.to_i,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
it "creates a new quota with valid params" do
|
||||
expect {
|
||||
post "/admin/plugins/discourse-ai/quotas.json", params: valid_params
|
||||
expect(response.status).to eq(201)
|
||||
}.to change(LlmQuota, :count).by(1)
|
||||
|
||||
quota = LlmQuota.last
|
||||
expect(quota.group_id).to eq(group.id)
|
||||
expect(quota.max_tokens).to eq(1000)
|
||||
end
|
||||
|
||||
it "fails with invalid params" do
|
||||
post "/admin/plugins/discourse-ai/quotas.json",
|
||||
params: {
|
||||
quota: valid_params[:quota].except(:group_id),
|
||||
}
|
||||
|
||||
expect(response.status).to eq(422)
|
||||
expect(LlmQuota.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#update" do
|
||||
fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) }
|
||||
|
||||
it "updates quota with valid params" do
|
||||
put "/admin/plugins/discourse-ai/quotas/#{quota.id}.json",
|
||||
params: {
|
||||
quota: {
|
||||
max_tokens: 2000,
|
||||
},
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
expect(quota.reload.max_tokens).to eq(2000)
|
||||
end
|
||||
|
||||
it "fails with invalid params" do
|
||||
put "/admin/plugins/discourse-ai/quotas/#{quota.id}.json",
|
||||
params: {
|
||||
quota: {
|
||||
duration_seconds: 0,
|
||||
},
|
||||
}
|
||||
|
||||
expect(response.status).to eq(422)
|
||||
expect(quota.reload.duration_seconds).not_to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
describe "#destroy" do
|
||||
fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) }
|
||||
|
||||
it "deletes the quota" do
|
||||
delete "/admin/plugins/discourse-ai/quotas/#{quota.id}.json"
|
||||
|
||||
expect(response.status).to eq(204)
|
||||
expect { quota.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
|
||||
it "returns 404 for non-existent quota" do
|
||||
delete "/admin/plugins/discourse-ai/quotas/9999.json"
|
||||
|
||||
expect(response.status).to eq(404)
|
||||
end
|
||||
end
|
||||
end
|
@ -20,6 +20,24 @@ RSpec.describe DiscourseAi::Admin::AiLlmsController do
|
||||
)
|
||||
end
|
||||
|
||||
fab!(:group)
|
||||
fab!(:quota) { Fabricate(:llm_quota, llm_model: llm_model, group: group) }
|
||||
fab!(:quota2) { Fabricate(:llm_quota, llm_model: llm_model, group: Fabricate(:group)) }
|
||||
|
||||
it "includes quotas in serialized response" do
|
||||
get "/admin/plugins/discourse-ai/ai-llms.json"
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
llms = response.parsed_body["ai_llms"]
|
||||
expect(llms.length).to eq(2)
|
||||
|
||||
model = llms.find { |m| m["id"] == llm_model.id }
|
||||
expect(model["llm_quotas"]).to be_present
|
||||
expect(model["llm_quotas"].length).to eq(2)
|
||||
expect(model["llm_quotas"].map { |q| q["id"] }).to contain_exactly(quota.id, quota2.id)
|
||||
end
|
||||
|
||||
it "includes all available providers metadata" do
|
||||
get "/admin/plugins/discourse-ai/ai-llms.json"
|
||||
expect(response).to be_successful
|
||||
@ -81,6 +99,27 @@ RSpec.describe DiscourseAi::Admin::AiLlmsController do
|
||||
}
|
||||
end
|
||||
|
||||
context "with quotas" do
|
||||
let(:group) { Fabricate(:group) }
|
||||
let(:quota_params) do
|
||||
[{ group_id: group.id, max_tokens: 1000, max_usages: 10, duration_seconds: 86_400 }]
|
||||
end
|
||||
|
||||
it "creates model with quotas" do
|
||||
post "/admin/plugins/discourse-ai/ai-llms.json",
|
||||
params: {
|
||||
ai_llm: valid_attrs.merge(llm_quotas: quota_params),
|
||||
}
|
||||
|
||||
expect(response.status).to eq(201)
|
||||
created_model = LlmModel.last
|
||||
expect(created_model.llm_quotas.count).to eq(1)
|
||||
quota = created_model.llm_quotas.first
|
||||
expect(quota.max_tokens).to eq(1000)
|
||||
expect(quota.group_id).to eq(group.id)
|
||||
end
|
||||
end
|
||||
|
||||
context "with valid attributes" do
|
||||
it "creates a new LLM model" do
|
||||
post "/admin/plugins/discourse-ai/ai-llms.json", params: { ai_llm: valid_attrs }
|
||||
@ -216,6 +255,71 @@ RSpec.describe DiscourseAi::Admin::AiLlmsController do
|
||||
context "with valid update params" do
|
||||
let(:update_attrs) { { provider: "anthropic" } }
|
||||
|
||||
context "with quotas" do
|
||||
it "updates quotas correctly" do
|
||||
group1 = Fabricate(:group)
|
||||
group2 = Fabricate(:group)
|
||||
group3 = Fabricate(:group)
|
||||
|
||||
_quota1 =
|
||||
Fabricate(
|
||||
:llm_quota,
|
||||
llm_model: llm_model,
|
||||
group: group1,
|
||||
max_tokens: 1000,
|
||||
max_usages: 10,
|
||||
duration_seconds: 86_400,
|
||||
)
|
||||
_quota2 =
|
||||
Fabricate(
|
||||
:llm_quota,
|
||||
llm_model: llm_model,
|
||||
group: group2,
|
||||
max_tokens: 2000,
|
||||
max_usages: 20,
|
||||
duration_seconds: 86_400,
|
||||
)
|
||||
|
||||
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
|
||||
params: {
|
||||
ai_llm: {
|
||||
llm_quotas: [
|
||||
{
|
||||
group_id: group1.id,
|
||||
max_tokens: 1500,
|
||||
max_usages: 15,
|
||||
duration_seconds: 43_200,
|
||||
},
|
||||
{
|
||||
group_id: group3.id,
|
||||
max_tokens: 3000,
|
||||
max_usages: 30,
|
||||
duration_seconds: 86_400,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
expect(response.status).to eq(200)
|
||||
|
||||
llm_model.reload
|
||||
expect(llm_model.llm_quotas.count).to eq(2)
|
||||
|
||||
updated_quota1 = llm_model.llm_quotas.find_by(group: group1)
|
||||
expect(updated_quota1.max_tokens).to eq(1500)
|
||||
expect(updated_quota1.max_usages).to eq(15)
|
||||
expect(updated_quota1.duration_seconds).to eq(43_200)
|
||||
|
||||
expect(llm_model.llm_quotas.find_by(group: group2)).to be_nil
|
||||
|
||||
new_quota = llm_model.llm_quotas.find_by(group: group3)
|
||||
expect(new_quota).to be_present
|
||||
expect(new_quota.max_tokens).to eq(3000)
|
||||
expect(new_quota.max_usages).to eq(30)
|
||||
expect(new_quota.duration_seconds).to eq(86_400)
|
||||
end
|
||||
end
|
||||
|
||||
it "updates the model" do
|
||||
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
|
||||
params: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user