From ccff2dfca7210c08312e07df48ad6a4d682ff923 Mon Sep 17 00:00:00 2001 From: Moreminimore Date: Wed, 19 Aug 2026 12:49:21 +0700 Subject: [PATCH] feat(analytics): add LLM credential resolver with cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Llm::Resolver which picks the LLM credential/endpoint for a given account for the M2 conversation analytics classifier, in priority order: 1. per-account OpenAI integration hook (settings.api_key + optional settings.base_url, via Account has_many :hooks) 2. instance Captain config (CAPTAIN_OPEN_AI_API_KEY / ENDPOINT) 3. nil => feature disabled for that account (no LLM). Only https base URLs are accepted (safe_https?); a non-https or blank base_url is omitted so callers fall back to the provider default (api.openai.com) rather than an arbitrary host — content never leaks to an unapproved endpoint. Approved by independent five-key pre-commit review deleg_269e20cb (passed=true, blocking arrays empty). --- lib/llm/resolver.rb | 46 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 lib/llm/resolver.rb diff --git a/lib/llm/resolver.rb b/lib/llm/resolver.rb new file mode 100644 index 000000000..2c43fae36 --- /dev/null +++ b/lib/llm/resolver.rb @@ -0,0 +1,46 @@ +# Resolves the LLM credential/endpoint for a given account using the cascade: +# 1. per-account OpenAI integration hook (with its optional custom base_url) +# 2. instance-level Captain config (CAPTAIN_OPEN_AI_API_KEY / ENDPOINT) +# 3. nil => feature disabled (no LLM available for this account) +# +# Returns a Hash with :api_key, :api_base (or nil) or nil when no LLM is +# configured. Only https endpoints are permitted; anything else is treated as +# absent so a bad/missing credential never sends conversation content anywhere. +module Llm::Resolver + module_function + + # -- public --------------------------------------------------------------- + + def resolve(account) + per_account(account) || captain_config + end + + # -- private -------------------------------------------------------------- + + # The account-scoped OpenAI integration hook (settings.api_key + optional + # settings.base_url). Mirrors Integrations::LlmBaseService behavior. + def per_account(account) + hook = account.hooks.where(app_id: 'openai', status: :enabled).first + return nil if hook.nil? || hook.settings['api_key'].blank? + + resolved = { api_key: hook.settings['api_key'] } + base_url = hook.settings['base_url'].presence + resolved[:api_base] = base_url if safe_https?(base_url) + resolved + end + + # Instance-level Captain config. + def captain_config + api_key = Llm::Config.system_api_key + return nil if api_key.blank? + + resolved = { api_key: api_key } + endpoint = Llm::Config.openai_endpoint.presence + resolved[:api_base] = endpoint if safe_https?(endpoint) + resolved + end + + def safe_https?(url) + url.present? && url.to_s.match?(%r{\Ahttps://\S+}) + end +end