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:
Sam 2025-01-14 15:54:09 +11:00 committed by GitHub
parent 20612fde52
commit d07cf51653
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 1684 additions and 151 deletions

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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
View 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)
#

View 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
#

View File

@ -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 =

View 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

View File

@ -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;
}

View File

@ -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"),
},
};

View File

@ -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,
}),
});

View File

@ -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}}

View 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>
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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">

View File

@ -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})]`;

View File

@ -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>
}

View File

@ -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)}}

View File

@ -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}}

View File

@ -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}}

View File

@ -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>
}

View File

@ -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,
});
}

View File

@ -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}}

View File

@ -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,
};
});

View File

@ -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 = "";

View File

@ -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"),
},
});
}

View File

@ -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 = "";

View File

@ -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}}

View File

@ -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"
}}
/>

View File

@ -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}}

View File

@ -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">

View 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 {

View File

@ -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,

View File

@ -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>`
);

View File

@ -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",

View 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);
}
}

View File

@ -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"

View File

@ -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.

View File

@ -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

View 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

View File

@ -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

View File

@ -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"

View 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

View 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

View 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

View 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

View 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

View File

@ -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: {