Files
sure/app/models/provider/registry.rb
Guillem Arias Fauste f25fe30a41 feat(ai): honor Setting.llm_provider for batch and PDF flows (#2265)
Auto-categorization, merchant detection/enhancement, and PDF/bank-statement
extraction hard-coded Provider::Registry.get_provider(:openai), so selecting
Anthropic (or running an Anthropic-only self-hosted install) left those
operations using/missing OpenAI rather than the chosen provider.

Add Provider::Registry.preferred_llm_provider, which resolves the LLM provider
honoring Setting.llm_provider with a configured-provider fallback (mirroring how
chat picks its provider), and route all six TODO(#2113) call sites through it:

- Family::AutoCategorizer#llm_provider
- Family::AutoMerchantDetector#llm_provider
- ProviderMerchant::Enhancer#llm_provider
- PdfImport (process_pdf + extract_bank_statement)
- Assistant::Function::ImportBankStatement

Provider::Anthropic already implements auto_categorize / auto_detect_merchants /
enhance_provider_merchants (#1984) and process_pdf / extract_bank_statement
(#1985), so no provider changes are needed — only the wiring.

Closes #2113.
2026-06-09 23:02:26 +02:00

183 lines
4.9 KiB
Ruby

class Provider::Registry
include ActiveModel::Validations
Error = Class.new(StandardError)
CONCEPTS = %i[exchange_rates securities llm]
validates :concept, inclusion: { in: CONCEPTS }
class << self
def for_concept(concept)
new(concept.to_sym)
end
def get_provider(name)
send(name)
rescue NoMethodError
raise Error.new("Provider '#{name}' not found in registry")
end
# Resolves the LLM provider for batch/PDF flows, honoring Setting.llm_provider
# the way chat does: prefer the configured provider, but fall back to whichever
# one actually has credentials so an install that swaps providers (or has only
# one configured) keeps working. Returns nil when neither is configured —
# callers guard on that.
def preferred_llm_provider
order = Setting.llm_provider == "anthropic" ? %i[anthropic openai] : %i[openai anthropic]
order.each do |name|
provider = get_provider(name)
return provider if provider
end
nil
end
def plaid_provider_for_region(region)
region.to_sym == :us ? plaid_us : plaid_eu
end
private
def stripe
secret_key = ENV["STRIPE_SECRET_KEY"]
webhook_secret = ENV["STRIPE_WEBHOOK_SECRET"]
return nil unless secret_key.present? && webhook_secret.present?
Provider::Stripe.new(secret_key:, webhook_secret:)
end
def twelve_data
api_key = ENV["TWELVE_DATA_API_KEY"].presence || Setting.twelve_data_api_key
return nil unless api_key.present?
Provider::TwelveData.new(api_key)
end
def plaid_us
Provider::PlaidAdapter.ensure_configuration_loaded
config = Rails.application.config.plaid
return nil unless config.present?
Provider::Plaid.new(config, region: :us)
end
def plaid_eu
Provider::PlaidEuAdapter.ensure_configuration_loaded
config = Rails.application.config.plaid_eu
return nil unless config.present?
Provider::Plaid.new(config, region: :eu)
end
def github
Provider::Github.new
end
def openai
access_token = ENV["OPENAI_ACCESS_TOKEN"].presence || Setting.openai_access_token
return nil unless access_token.present?
uri_base = ENV["OPENAI_URI_BASE"].presence || Setting.openai_uri_base
model = ENV["OPENAI_MODEL"].presence || Setting.openai_model
if uri_base.present? && model.blank?
Rails.logger.error("Custom OpenAI provider configured without a model; please set OPENAI_MODEL or Setting.openai_model")
return nil
end
Provider::Openai.new(access_token, uri_base: uri_base, model: model)
end
def anthropic
access_token = ENV["ANTHROPIC_ACCESS_TOKEN"].presence ||
ENV["ANTHROPIC_API_KEY"].presence ||
Setting.anthropic_access_token
return nil unless access_token.present?
base_url = ENV["ANTHROPIC_BASE_URL"].presence || Setting.anthropic_base_url
model = ENV["ANTHROPIC_MODEL"].presence || Setting.anthropic_model
Provider::Anthropic.new(access_token, base_url: base_url, model: model)
end
def yahoo_finance
Provider::YahooFinance.new
end
def tiingo
api_key = ENV["TIINGO_API_KEY"].presence || Setting.tiingo_api_key # pipelock:ignore
return nil unless api_key.present?
Provider::Tiingo.new(api_key)
end
def eodhd
api_key = ENV["EODHD_API_KEY"].presence || Setting.eodhd_api_key # pipelock:ignore
return nil unless api_key.present?
Provider::Eodhd.new(api_key)
end
def alpha_vantage
api_key = ENV["ALPHA_VANTAGE_API_KEY"].presence || Setting.alpha_vantage_api_key # pipelock:ignore
return nil unless api_key.present?
Provider::AlphaVantage.new(api_key)
end
def mfapi
Provider::Mfapi.new
end
def binance_public
Provider::BinancePublic.new
end
end
def initialize(concept)
@concept = concept
validate!
end
def providers
available_providers.map { |p| self.class.send(p) }.compact
end
# Returns the list of provider key names (symbols) registered for this concept.
def provider_keys
available_providers
end
def get_provider(name)
provider_method = available_providers.find { |p| p == name.to_sym }
raise Error.new("Provider '#{name}' not found for concept: #{concept}") unless provider_method.present?
self.class.send(provider_method)
end
private
attr_reader :concept
def available_providers
case concept
when :exchange_rates
%i[twelve_data yahoo_finance]
when :securities
%i[twelve_data yahoo_finance tiingo eodhd alpha_vantage mfapi binance_public]
when :llm
%i[openai anthropic]
else
%i[plaid_us plaid_eu github openai anthropic]
end
end
end