Files
discourse-ai/spec/requests/admin/ai_llms_controller_spec.rb
T
SamandKeegan George d07cf51653 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 <[email protected]>
2025-01-14 15:54:09 +11:00

481 lines
15 KiB
Ruby

# frozen_string_literal: true
RSpec.describe DiscourseAi::Admin::AiLlmsController do
fab!(:admin)
before do
sign_in(admin)
SiteSetting.ai_bot_enabled = true
end
describe "GET #index" do
fab!(:llm_model) { Fabricate(:llm_model, enabled_chat_bot: true) }
fab!(:llm_model2) { Fabricate(:llm_model) }
fab!(:ai_persona) do
Fabricate(
:ai_persona,
name: "Cool persona",
force_default_llm: true,
default_llm: "custom:#{llm_model2.id}",
)
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
expect(response.parsed_body["meta"]["providers"]).to contain_exactly(
*DiscourseAi::Completions::Llm.provider_names,
)
end
it "lists enabled features on appropriate LLMs" do
SiteSetting.ai_bot_enabled = true
# setting the setting calls the model
DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do
SiteSetting.ai_helper_model = "custom:#{llm_model.id}"
SiteSetting.ai_helper_enabled = true
end
DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do
SiteSetting.ai_summarization_model = "custom:#{llm_model2.id}"
SiteSetting.ai_summarization_enabled = true
end
DiscourseAi::Completions::Llm.with_prepared_responses(["OK"]) do
SiteSetting.ai_embeddings_semantic_search_hyde_model = "custom:#{llm_model2.id}"
SiteSetting.ai_embeddings_semantic_search_enabled = true
end
get "/admin/plugins/discourse-ai/ai-llms.json"
llms = response.parsed_body["ai_llms"]
model_json = llms.find { |m| m["id"] == llm_model.id }
expect(model_json["used_by"]).to contain_exactly(
{ "type" => "ai_bot" },
{ "type" => "ai_helper" },
)
model2_json = llms.find { |m| m["id"] == llm_model2.id }
expect(model2_json["used_by"]).to contain_exactly(
{ "type" => "ai_persona", "name" => "Cool persona", "id" => ai_persona.id },
{ "type" => "ai_summarization" },
{ "type" => "ai_embeddings_semantic_search" },
)
end
end
describe "POST #create" do
let(:valid_attrs) do
{
display_name: "My cool LLM",
name: "gpt-3.5",
provider: "open_ai",
url: "https://test.test/v1/chat/completions",
api_key: "test",
tokenizer: "DiscourseAi::Tokenizer::OpenAiTokenizer",
max_prompt_tokens: 16_000,
}
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 }
response_body = response.parsed_body
created_model = response_body["ai_llm"]
expect(created_model["display_name"]).to eq(valid_attrs[:display_name])
expect(created_model["name"]).to eq(valid_attrs[:name])
expect(created_model["provider"]).to eq(valid_attrs[:provider])
expect(created_model["tokenizer"]).to eq(valid_attrs[:tokenizer])
expect(created_model["max_prompt_tokens"]).to eq(valid_attrs[:max_prompt_tokens])
model = LlmModel.find(created_model["id"])
expect(model.display_name).to eq(valid_attrs[:display_name])
end
it "creates a companion user" do
post "/admin/plugins/discourse-ai/ai-llms.json",
params: {
ai_llm: valid_attrs.merge(enabled_chat_bot: true),
}
created_model = LlmModel.last
expect(created_model.user_id).to be_present
end
it "stores provider-specific config params" do
provider_params = { organization: "Discourse" }
post "/admin/plugins/discourse-ai/ai-llms.json",
params: {
ai_llm: valid_attrs.merge(provider_params: provider_params),
}
created_model = LlmModel.last
expect(created_model.lookup_custom_param("organization")).to eq(
provider_params[:organization],
)
end
it "ignores parameters not associated with that provider" do
provider_params = { access_key_id: "random_key" }
post "/admin/plugins/discourse-ai/ai-llms.json",
params: {
ai_llm: valid_attrs.merge(provider_params: provider_params),
}
created_model = LlmModel.last
expect(created_model.lookup_custom_param("access_key_id")).to be_nil
end
end
context "with invalid attributes" do
it "doesn't create a model" do
post "/admin/plugins/discourse-ai/ai-llms.json",
params: {
ai_llm: valid_attrs.except(:url),
}
created_model = LlmModel.last
expect(created_model).to be_nil
end
end
context "with provider-specific params" do
it "doesn't create a model if a Bedrock param is missing" do
post "/admin/plugins/discourse-ai/ai-llms.json",
params: {
ai_llm:
valid_attrs.merge(
provider: "aws_bedrock",
provider_params: {
region: "us-east-1",
},
),
}
created_model = LlmModel.last
expect(response.status).to eq(422)
expect(created_model).to be_nil
end
it "creates the model if all required provider params are present" do
post "/admin/plugins/discourse-ai/ai-llms.json",
params: {
ai_llm:
valid_attrs.merge(
provider: "aws_bedrock",
provider_params: {
region: "us-east-1",
access_key_id: "test",
},
),
}
created_model = LlmModel.last
expect(response.status).to eq(201)
expect(created_model.lookup_custom_param("region")).to eq("us-east-1")
expect(created_model.lookup_custom_param("access_key_id")).to eq("test")
end
it "supports boolean values" do
post "/admin/plugins/discourse-ai/ai-llms.json",
params: {
ai_llm:
valid_attrs.merge(
provider: "vllm",
provider_params: {
disable_system_prompt: true,
},
),
}
created_model = LlmModel.last
expect(response.status).to eq(201)
expect(created_model.lookup_custom_param("disable_system_prompt")).to eq(true)
end
end
end
describe "PUT #update" do
fab!(:llm_model)
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: {
ai_llm: update_attrs,
}
expect(response.status).to eq(200)
expect(llm_model.reload.provider).to eq(update_attrs[:provider])
end
it "returns a 404 if there is no model with the given Id" do
put "/admin/plugins/discourse-ai/ai-llms/9999999.json"
expect(response.status).to eq(404)
end
it "creates a companion user" do
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
params: {
ai_llm: update_attrs.merge(enabled_chat_bot: true),
}
expect(llm_model.reload.user_id).to be_present
end
it "removes the companion user when desabling the chat bot option" do
llm_model.update!(enabled_chat_bot: true)
llm_model.toggle_companion_user
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
params: {
ai_llm: update_attrs.merge(enabled_chat_bot: false),
}
expect(llm_model.reload.user_id).to be_nil
end
end
context "with invalid update params" do
it "doesn't update the model" do
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
params: {
ai_llm: {
url: "",
},
}
expect(response.status).to eq(422)
end
end
context "with provider-specific params" do
it "updates provider-specific config params" do
provider_params = { organization: "Discourse" }
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
params: {
ai_llm: {
provider_params: provider_params,
},
}
expect(llm_model.reload.lookup_custom_param("organization")).to eq(
provider_params[:organization],
)
end
it "ignores parameters not associated with that provider" do
provider_params = { access_key_id: "random_key" }
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
params: {
ai_llm: {
provider_params: provider_params,
},
}
expect(llm_model.reload.lookup_custom_param("access_key_id")).to be_nil
end
end
end
describe "GET #test" do
let(:test_attrs) do
{
name: "llama3",
provider: "hugging_face",
url: "https://test.test/v1/chat/completions",
api_key: "test",
tokenizer: "DiscourseAi::Tokenizer::Llama3Tokenizer",
max_prompt_tokens: 2_000,
}
end
context "when we can contact the model" do
it "returns a success true flag" do
DiscourseAi::Completions::Llm.with_prepared_responses(["a response"]) do
get "/admin/plugins/discourse-ai/ai-llms/test.json", params: { ai_llm: test_attrs }
expect(response).to be_successful
expect(response.parsed_body["success"]).to eq(true)
end
end
end
context "when we cannot contact the model" do
it "returns a success false flag and the error message" do
error_message = {
error:
"Input validation error: `inputs` tokens + `max_new_tokens` must be <= 1512. Given: 30 `inputs` tokens and 3984 `max_new_tokens`",
error_type: "validation",
}
WebMock.stub_request(:post, test_attrs[:url]).to_return(
status: 422,
body: error_message.to_json,
)
get "/admin/plugins/discourse-ai/ai-llms/test.json", params: { ai_llm: test_attrs }
expect(response).to be_successful
expect(response.parsed_body["success"]).to eq(false)
expect(response.parsed_body["error"]).to eq(error_message.to_json)
end
end
end
describe "DELETE #destroy" do
fab!(:llm_model)
it "destroys the requested ai_persona" do
expect {
delete "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json"
expect(response).to have_http_status(:no_content)
}.to change(LlmModel, :count).by(-1)
end
it "validates the model is not in use" do
fake_llm = assign_fake_provider_to(:ai_helper_model)
delete "/admin/plugins/discourse-ai/ai-llms/#{fake_llm.id}.json"
expect(response.status).to eq(409)
expect(fake_llm.reload).to eq(fake_llm)
end
it "cleans up companion users before deleting the model" do
llm_model.update!(enabled_chat_bot: true)
llm_model.toggle_companion_user
companion_user = llm_model.user
delete "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json"
expect { companion_user.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end