discourse/plugins/discourse-ai/lib/configuration/llm_validator.rb
Rafael dos Santos Silva 0dfc9e997f
DEV: exercise streaming in LLM model Run Test (#39983)
## Summary
- The "Run Test" button on the Discourse AI LLM admin form only issued a
non-streaming completion, so configurations that worked for that probe
could still fail in production when a streaming response was requested.
- `LlmValidator#run_test` now runs the non-streaming probe followed by a
streaming probe (block-form `Llm#generate`) and raises if either returns
nothing. It tracks `last_failed_mode` (`:non_streaming` / `:streaming`).
- The controller surfaces `failed_mode` in the JSON response and the
admin form renders a mode-specific error string so operators can see
which path is broken.
- Per-user rate limit on `POST /admin/plugins/discourse-ai/ai-llms/test`
doubled (3 → 6 / minute) to absorb the extra request per click.

## Test plan
- [ ] `bin/rspec
plugins/discourse-ai/spec/configuration/llm_validator_spec.rb`
- [ ] `bin/rspec
plugins/discourse-ai/spec/requests/admin/ai_llms_controller_spec.rb -e
"POST #test"`
- [ ] In admin UI, click "Run test" on an LLM config and confirm both
probes are exercised (success path).
- [ ] Configure an endpoint where streaming is broken and confirm the
form reports "Streaming request failed: …".
2026-05-13 14:09:57 -03:00

104 lines
2.6 KiB
Ruby
Vendored

# frozen_string_literal: true
module DiscourseAi
module Configuration
class LlmValidator
TEST_PROMPT = "How much is 1 + 1?"
attr_reader :last_failed_mode
def initialize(opts = {})
@opts = opts
end
def valid_value?(val)
if val == ""
if @opts[:name] == :ai_default_llm_model
@parent_module_names = []
enabled_settings.each do |setting_name|
if SiteSetting.public_send(setting_name) == true
@parent_module_names << setting_name
@parent_enabled = true
end
end
return !@parent_enabled
end
end
run_test(val).tap { |result| @unreachable = result }
rescue StandardError => e
raise e if Rails.env.test?
@unreachable = true
true
end
def run_test(val)
llm = DiscourseAi::Completions::Llm.proxy(val)
@last_failed_mode = :non_streaming
raise empty_response_error if probe(llm).blank?
@last_failed_mode = :streaming
streamed = +""
probe(llm) { |partial| streamed << partial.to_s if partial.is_a?(String) }
raise empty_response_error if streamed.blank?
@last_failed_mode = nil
true
end
def is_using(llm_model)
in_use_by = AiAgent.where(default_llm_id: llm_model.id).pluck(:name)
in_use_by << "ai_default_llm_model" if SiteSetting.ai_default_llm_model.to_i == llm_model.id
in_use_by
end
def error_message
if @parent_enabled && @parent_module_names.present?
return(
I18n.t(
"discourse_ai.llm.configuration.disable_modules_first",
settings: @parent_module_names.join(", "),
)
)
end
return unless @unreachable
I18n.t("discourse_ai.llm.configuration.model_unreachable")
end
def enabled_settings
%i[
ai_embeddings_semantic_search_enabled
ai_helper_enabled
ai_summarization_enabled
ai_translation_enabled
]
end
private
def probe(llm, &blk)
llm.generate(
TEST_PROMPT,
user: @opts[:user] || Discourse.system_user,
feature_name: "llm_validator",
temperature: 0.7,
top_p: 0.9,
&blk
)
end
def empty_response_error
DiscourseAi::Completions::Endpoints::Base::CompletionFailed.new(
I18n.t("discourse_ai.llm.configuration.empty_response"),
)
end
end
end
end