Skip to content
This repository was archived by the owner on Jul 22, 2025. It is now read-only.

Commit f10a975

Browse files
committed
working on quota creation
we need to route through llm model for simplicity
1 parent 97fa6b2 commit f10a975

File tree

10 files changed

+294
-3
lines changed

10 files changed

+294
-3
lines changed

app/controllers/discourse_ai/admin/ai_llms_controller.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ def edit
4040

4141
def create
4242
llm_model = LlmModel.new(ai_llm_params)
43+
44+
# we could do nested attributes but the mechanics are not ideal leading
45+
# to lots of complex debugging, this is simpler
46+
quota_params.each { |quota| llm_model.llm_quotas.build(quota) } if quota_params
47+
4348
if llm_model.save
4449
llm_model.toggle_companion_user
4550
render json: LlmModelSerializer.new(llm_model), status: :created
@@ -110,6 +115,19 @@ def test
110115

111116
private
112117

118+
def quota_params
119+
if params[:ai_llm][:llm_quotas].present?
120+
params[:ai_llm][:llm_quotas].map do |quota|
121+
mapped = {}
122+
mapped[:group_id] = quota[:group_id].to_i
123+
mapped[:max_tokens] = quota[:max_tokens].to_i if quota[:max_tokens].present?
124+
mapped[:max_usages] = quota[:max_usages].to_i if quota[:max_usages].present?
125+
mapped[:duration_seconds] = quota[:duration_seconds].to_i
126+
mapped
127+
end
128+
end
129+
end
130+
113131
def ai_llm_params(updating: nil)
114132
return {} if params[:ai_llm].blank?
115133

app/models/llm_model.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ class LlmModel < ActiveRecord::Base
44
FIRST_BOT_USER_ID = -1200
55
BEDROCK_PROVIDER_NAME = "aws_bedrock"
66

7-
belongs_to :user
87
has_many :llm_quotas, dependent: :destroy
8+
belongs_to :user
99

1010
validates :display_name, presence: true, length: { maximum: 100 }
1111
validates :tokenizer, presence: true, inclusion: DiscourseAi::Completions::Llm.tokenizer_names

app/models/llm_quota.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ class LlmQuota < ActiveRecord::Base
88
has_many :llm_quota_usages
99

1010
validates :group_id, presence: true
11-
validates :llm_model_id, presence: true
11+
# we can not validate on create cause it breaks build
12+
validates :llm_model_id, presence: true, on: :update
1213
validates :duration_seconds, presence: true, numericality: { greater_than: 0 }
1314
validates :max_tokens, numericality: { greater_than: 0, allow_nil: true }
1415
validates :max_usages, numericality: { greater_than: 0, allow_nil: true }

assets/javascripts/discourse/admin/models/ai-llm.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export default class AiLlm extends RestModel {
2121
updateProperties() {
2222
const attrs = this.createProperties();
2323
attrs.id = this.id;
24-
24+
attrs.llm_quotas = this.llm_quotas;
2525
return attrs;
2626
}
2727

assets/javascripts/discourse/components/ai-llm-editor-form.gjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import I18n from "discourse-i18n";
1717
import AdminUser from "admin/models/admin-user";
1818
import ComboBox from "select-kit/components/combo-box";
1919
import DTooltip from "float-kit/components/d-tooltip";
20+
import AiLlmQuotaEditor from "./ai-llm-quota-editor";
2021

2122
export default class AiLlmEditorForm extends Component {
2223
@service toasts;
@@ -317,6 +318,13 @@ export default class AiLlmEditorForm extends Component {
317318
</div>
318319
{{/if}}
319320

321+
{{#unless @model.isNew}}
322+
<div class="control-group">
323+
<label>{{i18n "discourse_ai.llms.quotas.title"}}</label>
324+
<AiLlmQuotaEditor @model={{@model}} @groups={{@groups}} />
325+
</div>
326+
{{/unless}}
327+
320328
<div class="control-group ai-llm-editor__action_panel">
321329
<DButton
322330
class="ai-llm-editor__test"
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import Component from "@glimmer/component";
2+
import { tracked } from "@glimmer/tracking";
3+
import { fn, hash } from "@ember/helper";
4+
import { on } from "@ember/modifier";
5+
import { action } from "@ember/object";
6+
import { service } from "@ember/service";
7+
import DButton from "discourse/components/d-button";
8+
import I18n from "discourse-i18n";
9+
import GroupChooser from "select-kit/components/group-chooser";
10+
11+
export default class AiLlmQuotaEditor extends Component {
12+
@service store;
13+
@service dialog;
14+
@service site;
15+
16+
@tracked newQuotaGroupIds = null;
17+
@tracked newQuotaTokens = null;
18+
@tracked newQuotaUsages = null;
19+
@tracked newQuotaDuration = 86400; // 1 day default
20+
21+
get canAddQuota() {
22+
return (
23+
this.newQuotaGroupId &&
24+
(this.newQuotaTokens || this.newQuotaUsages) &&
25+
this.newQuotaDuration
26+
);
27+
}
28+
29+
@action
30+
updateQuotaTokens(event) {
31+
this.newQuotaTokens = event.target.value;
32+
}
33+
34+
@action
35+
updateQuotaUsages(event) {
36+
this.newQuotaUsages = event.target.value;
37+
}
38+
39+
@action
40+
updateQuotaDuration(event) {
41+
this.newQuotaDuration = event.target.value;
42+
}
43+
44+
@action
45+
updateGroups(groups) {
46+
this.newQuotaGroupIds = groups;
47+
}
48+
49+
@action
50+
async addQuota() {
51+
const quota = {
52+
group_id: this.newQuotaGroupIds[0],
53+
group_name: this.site.groups.findBy("id", this.newQuotaGroupIds[0]).name,
54+
llm_model_id: this.args.model.id,
55+
max_tokens: this.newQuotaTokens,
56+
max_usages: this.newQuotaUsages,
57+
duration_seconds: this.newQuotaDuration,
58+
};
59+
this.args.model.llm_quotas.pushObject(quota);
60+
}
61+
62+
@action
63+
async deleteQuota(quota) {
64+
this.args.model.llm_quotas.removeObject(quota);
65+
}
66+
67+
<template>
68+
<div class="ai-llm-quotas">
69+
<table class="ai-llm-quotas__table">
70+
<thead class="ai-llm-quotas__table-head">
71+
<tr class="ai-llm-quotas__header-row">
72+
<th class="ai-llm-quotas__header">{{I18n.t
73+
"discourse_ai.llms.quotas.group"
74+
}}</th>
75+
<th class="ai-llm-quotas__header">{{I18n.t
76+
"discourse_ai.llms.quotas.max_tokens"
77+
}}</th>
78+
<th class="ai-llm-quotas__header">{{I18n.t
79+
"discourse_ai.llms.quotas.max_usages"
80+
}}</th>
81+
<th class="ai-llm-quotas__header">{{I18n.t
82+
"discourse_ai.llms.quotas.duration"
83+
}}</th>
84+
<th
85+
class="ai-llm-quotas__header ai-llm-quotas__header--actions"
86+
></th>
87+
</tr>
88+
</thead>
89+
<tbody class="ai-llm-quotas__table-body">
90+
{{#each @model.llm_quotas as |quota|}}
91+
<tr class="ai-llm-quotas__row">
92+
<td class="ai-llm-quotas__cell">{{quota.group_name}}</td>
93+
<td class="ai-llm-quotas__cell">
94+
<input
95+
type="number"
96+
value={{quota.max_tokens}}
97+
class="ai-llm-quotas__input"
98+
min="1"
99+
/>
100+
</td>
101+
<td class="ai-llm-quotas__cell">
102+
<input
103+
type="number"
104+
value={{quota.max_usages}}
105+
class="ai-llm-quotas__input"
106+
min="1"
107+
/>
108+
</td>
109+
<td class="ai-llm-quotas__cell">
110+
<input
111+
type="number"
112+
value={{quota.duration_seconds}}
113+
class="ai-llm-quotas__input"
114+
min="1"
115+
/>
116+
</td>
117+
<td class="ai-llm-quotas__cell ai-llm-quotas__cell--actions">
118+
<DButton
119+
@icon="trash-alt"
120+
class="btn-danger ai-llm-quotas__delete-btn"
121+
{{on "click" (fn this.deleteQuota quota)}}
122+
/>
123+
</td>
124+
</tr>
125+
{{/each}}
126+
<tr class="ai-llm-quotas__row ai-llm-quotas__row--new">
127+
<td class="ai-llm-quotas__cell">
128+
<GroupChooser
129+
@value={{this.newQuotaGroupIds}}
130+
@content={{this.site.groups}}
131+
@onChange={{this.updateGroups}}
132+
@options={{hash maximum=1}}
133+
/>
134+
</td>
135+
<td class="ai-llm-quotas__cell">
136+
<input
137+
type="number"
138+
value={{this.newQuotaTokens}}
139+
class="ai-llm-quotas__input"
140+
{{on "input" this.updateQuotaTokens}}
141+
min="1"
142+
/>
143+
</td>
144+
<td class="ai-llm-quotas__cell">
145+
<input
146+
type="number"
147+
value={{this.newQuotaUsages}}
148+
class="ai-llm-quotas__input"
149+
{{on "input" this.updateQuotaUsages}}
150+
min="1"
151+
/>
152+
</td>
153+
<td class="ai-llm-quotas__cell">
154+
<input
155+
type="number"
156+
value={{this.newQuotaDuration}}
157+
class="ai-llm-quotas__input"
158+
{{on "input" this.updateQuotaDuration}}
159+
min="1"
160+
/>
161+
</td>
162+
<td class="ai-llm-quotas__cell ai-llm-quotas__cell--actions">
163+
<DButton
164+
@action={{this.addQuota}}
165+
@icon="plus"
166+
class="btn-primary ai-llm-quotas__add-btn"
167+
/>
168+
</td>
169+
</tr>
170+
</tbody>
171+
</table>
172+
</div>
173+
</template>
174+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
.ai-llm-quotas {
2+
margin: 1em 0;
3+
4+
&__table {
5+
width: 100%;
6+
border-collapse: collapse;
7+
margin-bottom: 1em;
8+
}
9+
10+
&__table-head {
11+
background-color: var(--primary-very-low);
12+
}
13+
14+
&__header {
15+
text-align: left;
16+
padding: 0.5em;
17+
font-weight: bold;
18+
border-bottom: 2px solid var(--primary-low);
19+
20+
&--actions {
21+
width: 50px;
22+
}
23+
}
24+
25+
&__row {
26+
border-bottom: 1px solid var(--primary-low);
27+
28+
&--new {
29+
background-color: var(--primary-very-low);
30+
}
31+
}
32+
33+
&__cell {
34+
padding: 0.5em;
35+
vertical-align: middle;
36+
37+
&--actions {
38+
text-align: center;
39+
}
40+
}
41+
42+
&__input {
43+
width: 100px;
44+
padding: 0.5em;
45+
border: 1px solid var(--primary-low);
46+
border-radius: 3px;
47+
}
48+
49+
&__group-select {
50+
width: 200px;
51+
}
52+
53+
&__delete-btn {
54+
padding: 0.3em 0.5em;
55+
}
56+
57+
&__add-btn {
58+
padding: 0.3em 0.5em;
59+
}
60+
}

config/locales/client.en.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,13 @@ en:
337337
confirm_delete: Are you sure you want to delete this model?
338338
delete: Delete
339339
seeded_warning: "This model is pre-configured on your site and cannot be edited."
340+
quotas:
341+
title: "Usage Quotas"
342+
group: "Group"
343+
max_tokens: "Max Tokens"
344+
max_usages: "Max Uses"
345+
duration: "Duration (seconds)"
346+
confirm_delete: "Are you sure you want to delete this quota?"
340347
usage:
341348
ai_bot: "AI bot"
342349
ai_helper: "Helper"

plugin.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838

3939
register_asset "stylesheets/modules/llms/common/usage.scss"
4040
register_asset "stylesheets/modules/llms/common/spam.scss"
41+
register_asset "stylesheets/modules/llms/common/ai-llm-quotas.scss"
4142

4243
register_asset "stylesheets/modules/ai-bot/common/ai-tools.scss"
4344

spec/requests/admin/ai_llms_controller_spec.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,28 @@
9999
}
100100
end
101101

102+
context "with quotas" do
103+
let(:group) { Fabricate(:group) }
104+
let(:quota_params) do
105+
[{ group_id: group.id, max_tokens: 1000, max_usages: 10, duration_seconds: 86_400 }]
106+
end
107+
108+
it "creates model with quotas" do
109+
post "/admin/plugins/discourse-ai/ai-llms.json",
110+
params: {
111+
ai_llm: valid_attrs.merge(llm_quotas: quota_params),
112+
}
113+
114+
puts response.body
115+
expect(response.status).to eq(201)
116+
created_model = LlmModel.last
117+
expect(created_model.llm_quotas.count).to eq(1)
118+
quota = created_model.llm_quotas.first
119+
expect(quota.max_tokens).to eq(1000)
120+
expect(quota.group_id).to eq(group.id)
121+
end
122+
end
123+
102124
context "with valid attributes" do
103125
it "creates a new LLM model" do
104126
post "/admin/plugins/discourse-ai/ai-llms.json", params: { ai_llm: valid_attrs }

0 commit comments

Comments
 (0)