mirror of
https://github.com/discourse/discourse-ai.git
synced 2025-07-01 12:02:16 +00:00
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.
526 lines
17 KiB
Ruby
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
|