discourse-ai/spec/requests/admin/ai_llms_controller_spec.rb
Keegan George 9be1049de6
DEV: Log AI related configuration to staff action log (#1416)
is update adds logging for changes made in the AI admin panel. When making configuration changes to Embeddings, LLMs, Personas, Tools, or Spam that aren't site setting related, changes will now be logged in Admin > Logs & Screening. This will help admins debug issues related to AI. In this update a helper lib is created called `AiStaffActionLogger` which can be easily used in the future to add logging support for any other admin config we need logged for AI.
2025-06-12 12:39:58 -07:00

526 lines
17 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_id: 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 "logs staff action when creating an LLM model" do
# Log the creation
post "/admin/plugins/discourse-ai/ai-llms.json", params: { ai_llm: valid_attrs }
expect(response.status).to eq(201)
# Now verify the log was created with the right subject
history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "create_ai_llm_model").last
expect(history).to be_present
expect(history.subject).to eq(valid_attrs[:display_name]) # Verify subject is set to 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 "logs staff action when updating an LLM model" do
# The initial provider is different from the update
original_provider = llm_model.provider
display_name = llm_model.display_name
# Perform the update
put "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json",
params: {
ai_llm: update_attrs,
}
expect(response.status).to eq(200)
# Now verify the log was created with the right subject
history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "update_ai_llm_model").last
expect(history).to be_present
expect(history.subject).to eq(display_name) # Verify subject is set to display_name
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 "logs staff action when deleting an LLM model" do
# Capture the model details before deletion for comparison
model_id = llm_model.id
model_display_name = llm_model.display_name
# Delete the model
delete "/admin/plugins/discourse-ai/ai-llms/#{llm_model.id}.json"
expect(response).to have_http_status(:no_content)
# Now verify the log was created with the right subject
history = UserHistory.where(action: UserHistory.actions[:custom_staff], custom_type: "delete_ai_llm_model").last
expect(history).to be_present
expect(history.subject).to eq(model_display_name) # Verify subject is set to display_name
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