diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 35b068649..67d27a849 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -12,6 +12,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController @installation_configs = ConfigLoader.new.general_configs.each_with_object({}) do |config_hash, result| result[config_hash['name']] = config_hash.except('name') end + populate_captain_config_metadata if @config == 'captain' end def create @@ -71,19 +72,31 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController end def restart_required_config_saved? - params.fetch('app_config', {}).keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) + saved_keys = params.fetch('app_config', {}).keys + saved_keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) || saved_keys.intersect?(Llm::Config.provider_config_keys) end def captain_config_options - %w[ - CAPTAIN_OPEN_AI_API_KEY - CAPTAIN_OPEN_AI_MODEL - CAPTAIN_OPEN_AI_ENDPOINT - CAPTAIN_ANTHROPIC_API_KEY - CAPTAIN_ANTHROPIC_API_BASE - CAPTAIN_GEMINI_API_KEY - CAPTAIN_GEMINI_API_BASE - ] + (Llm::Config.provider_config_keys + %w[CAPTAIN_OPEN_AI_MODEL]).uniq + end + + def populate_captain_config_metadata + @app_config['CAPTAIN_LLM_PROVIDER'] ||= Llm::Config.current_provider + + @installation_configs['CAPTAIN_LLM_PROVIDER'] = { + 'display_title' => 'LLM Provider', + 'description' => 'Provider used to populate Captain model override dropdowns.', + 'type' => 'select', + 'options' => Llm::Config.provider_options + } + + Llm::Config.provider_config_options.each do |option, config_key| + @installation_configs[config_key] ||= { + 'display_title' => option.to_s.humanize.titleize, + 'description' => "RubyLLM #{option} configuration.", + 'type' => option.to_s.end_with?('api_key', 'secret_key', 'session_token', 'service_account_key', 'auth_token') ? 'secret' : 'text' + } + end end end diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb index 4444431f7..36eada464 100644 --- a/app/models/installation_config.rb +++ b/app/models/installation_config.rb @@ -19,6 +19,7 @@ class InstallationConfig < ApplicationRecord CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_ENDPOINT CAPTAIN_OPEN_AI_MODEL + CAPTAIN_LLM_PROVIDER CAPTAIN_ANTHROPIC_API_KEY CAPTAIN_ANTHROPIC_API_BASE CAPTAIN_GEMINI_API_KEY diff --git a/config/installation_config.yml b/config/installation_config.yml index 6a3b40f82..b16a18e2b 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -195,6 +195,12 @@ display_title: 'OpenAI API Endpoint (optional)' description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/' locked: false +- name: CAPTAIN_LLM_PROVIDER + display_title: 'LLM Provider' + description: 'Provider used to populate Captain model override dropdowns.' + value: openai + locked: false + type: select - name: CAPTAIN_ANTHROPIC_API_KEY display_title: 'Anthropic API Key' description: 'The API key used to authenticate requests to Anthropic models for Captain AI.' diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index a71841204..2a818bccc 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -41,14 +41,7 @@ module Enterprise::SuperAdmin::AppConfigsController end def captain_config_options - %w[ - CAPTAIN_OPEN_AI_API_KEY - CAPTAIN_OPEN_AI_MODEL - CAPTAIN_OPEN_AI_ENDPOINT - CAPTAIN_ANTHROPIC_API_KEY - CAPTAIN_ANTHROPIC_API_BASE - CAPTAIN_GEMINI_API_KEY - CAPTAIN_GEMINI_API_BASE + super + %w[ CAPTAIN_EMBEDDING_MODEL CAPTAIN_FIRECRAWL_API_KEY ] diff --git a/enterprise/app/services/captain/llm/embedding_service.rb b/enterprise/app/services/captain/llm/embedding_service.rb index c78c70f23..16a407779 100644 --- a/enterprise/app/services/captain/llm/embedding_service.rb +++ b/enterprise/app/services/captain/llm/embedding_service.rb @@ -17,7 +17,11 @@ class Captain::Llm::EmbeddingService return [] if content.blank? instrument_embedding_call(instrumentation_params(content, model)) do - RubyLLM.embed(content, model: model).vectors + raise EmbeddingsError, 'OpenAI configuration is required for embeddings.' unless Llm::Config.provider_configured?(Llm::Config::DEFAULT_PROVIDER) + + Llm::Config.with_provider(provider: Llm::Config::DEFAULT_PROVIDER) do |context| + context.embed(content, model: model, provider: Llm::Config::DEFAULT_PROVIDER, assume_model_exists: true).vectors + end end rescue RubyLLM::Error => e Rails.logger.error "Embedding API Error: #{e.message}" diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb index c45559165..7902e0f1b 100644 --- a/enterprise/lib/captain/conversation_completion_service.rb +++ b/enterprise/lib/captain/conversation_completion_service.rb @@ -58,8 +58,9 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService # This is an internal operational evaluation, not a customer-triggered feature, # so it should always use the installation key. - def llm_credential - @llm_credential ||= system_llm_credential + def llm_credential(provider = llm_provider) + @llm_credentials ||= {} + @llm_credentials[provider.to_s] ||= system_llm_credential(provider) end def counts_toward_usage? diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index 7992f9ef7..c1041c177 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -76,7 +76,7 @@ class Captain::BaseTaskService provider = llm_route[:provider] credential = llm_credential(provider) - Llm::Config.with_api_key(credential[:api_key], provider: provider, api_base: api_base) do |context| + Llm::Config.with_provider(provider: provider, config_values: credential[:config_values]) do |context| chat = build_chat(context, llm_route: llm_route, messages: messages, schema: schema, tools: tools) conversation_messages = messages.reject { |m| m[:role] == 'system' } diff --git a/lib/integrations/llm_base_service.rb b/lib/integrations/llm_base_service.rb index fbc92cf14..1128b19d2 100644 --- a/lib/integrations/llm_base_service.rb +++ b/lib/integrations/llm_base_service.rb @@ -103,7 +103,7 @@ class Integrations::LlmBaseService credential = llm_credential return { error: I18n.t('captain.api_key_missing'), error_code: 401, request_messages: messages } if credential.blank? - Llm::Config.with_api_key(credential[:api_key], provider: llm_provider, api_base: api_base) do |context| + Llm::Config.with_provider(provider: llm_provider, config_values: credential[:config_values]) do |context| chat = Llm::ProviderChat.new(context.chat(model: model, provider: llm_provider, assume_model_exists: true), provider: llm_provider) setup_chat_with_messages(chat, messages) end diff --git a/lib/llm/config.rb b/lib/llm/config.rb index 59b001935..2e5cd1f30 100644 --- a/lib/llm/config.rb +++ b/lib/llm/config.rb @@ -1,24 +1,12 @@ require 'ruby_llm' +require_relative 'provider_config' module Llm::Config + extend Llm::ProviderConfig + DEFAULT_MODEL = 'gpt-4.1-mini'.freeze DEFAULT_PROVIDER = 'openai'.freeze - PROVIDER_CONFIGS = { - 'openai' => { - api_key: 'CAPTAIN_OPEN_AI_API_KEY', - api_base: 'CAPTAIN_OPEN_AI_ENDPOINT' - }, - 'anthropic' => { - api_key: 'CAPTAIN_ANTHROPIC_API_KEY', - api_base: 'CAPTAIN_ANTHROPIC_API_BASE' - }, - 'gemini' => { - api_key: 'CAPTAIN_GEMINI_API_KEY', - api_base: 'CAPTAIN_GEMINI_API_BASE' - } - }.freeze - class << self def initialized? = @initialized ||= false @@ -31,99 +19,38 @@ module Llm::Config def reset! = @initialized = false - def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil) + def with_api_key(api_key, provider: DEFAULT_PROVIDER, api_base: nil, config_values: nil) initialize! context = RubyLLM.context do |config| - configure_provider(config, provider: provider, api_key: api_key, api_base: api_base) + values = config_values || provider_config_values(provider).merge( + :"#{provider}_api_key" => api_key, + :"#{provider}_api_base" => api_base + ).compact + configure_provider(config, provider: provider, config_values: values) end yield context end - def ruby_llm_provider_supported?(provider) - RubyLLM::Provider.providers.key?(provider.to_s.to_sym) - end - - def provider_options - PROVIDER_CONFIGS.keys.each_with_object({}) do |provider, result| - next unless ruby_llm_provider_supported?(provider) - - result[provider] = ruby_llm_provider_name(provider) + def with_provider(provider:, config_values: provider_config_values(provider)) + initialize! + context = RubyLLM.context do |config| + configure_provider(config, provider: provider, config_values: config_values) end - end - def api_key_for(provider) - installation_config_value(provider, :api_key) - end - - def api_base_for(provider) - api_base = installation_config_value(provider, :api_base).presence - return if api_base.blank? - - normalized_api_base(provider, api_base) - end - - def provider_configured?(provider) - api_key_for(provider).present? - end - - def openai_provider?(provider) - provider.to_s == DEFAULT_PROVIDER - end - - def supports_tools_and_schema?(provider) - openai_provider?(provider) - end - - def configure_provider(config, provider:, api_key:, api_base: nil) - provider = provider.to_s - options = provider_configuration_options(provider) - api_key_option = :"#{provider}_api_key" - api_base_option = :"#{provider}_api_base" - - set_config_value(config, api_key_option, api_key) if api_key.present? && options.include?(api_key_option) - set_config_value(config, api_base_option, api_base) if api_base.present? && options.include?(api_base_option) + yield context end private def configure_ruby_llm RubyLLM.configure do |config| - PROVIDER_CONFIGS.each_key do |provider| - next unless ruby_llm_provider_supported?(provider) - - configure_provider(config, provider: provider, api_key: api_key_for(provider), api_base: api_base_for(provider)) + provider_options.each_key do |provider| + configure_provider(config, provider: provider, config_values: provider_config_values(provider)) end config.model_registry_file = Rails.root.join('config/llm_models.json').to_s config.logger = Rails.logger end end - - def ruby_llm_provider_name(provider) - RubyLLM::Provider.providers[provider.to_s.to_sym].name - end - - def provider_configuration_options(provider) - RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || [] - end - - def set_config_value(config, option, value) - setter = :"#{option}=" - config.public_send(setter, value) if config.respond_to?(setter) - end - - def installation_config_value(provider, key) - config_name = PROVIDER_CONFIGS.dig(provider.to_s, key) - return if config_name.blank? - - InstallationConfig.find_by(name: config_name)&.value - end - - def normalized_api_base(provider, api_base) - endpoint = api_base.chomp('/').delete_suffix('/chat/completions') - return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1') - - endpoint - end end end diff --git a/lib/llm/credential_resolver.rb b/lib/llm/credential_resolver.rb index 411408890..d567bafbf 100644 --- a/lib/llm/credential_resolver.rb +++ b/lib/llm/credential_resolver.rb @@ -16,11 +16,13 @@ class Llm::CredentialResolver return unless Llm::Config.openai_provider?(provider) key = openai_hook&.settings&.dig('api_key').presence - { api_key: key, provider: provider, source: :hook } if key + { api_key: key, config_values: { openai_api_key: key }, provider: provider, source: :hook } if key end def system_llm_credential - key = Llm::Config.api_key_for(provider).presence - { api_key: key, provider: provider, source: :system } if key + config_values = Llm::Config.provider_config_values(provider) + return unless Llm::Config.provider_configured?(provider) + + { api_key: config_values[:"#{provider}_api_key"], config_values: config_values, provider: provider, source: :system } end end diff --git a/lib/llm/models.rb b/lib/llm/models.rb index 18a09c6ca..c34dae74d 100644 --- a/lib/llm/models.rb +++ b/lib/llm/models.rb @@ -1,8 +1,12 @@ module Llm::Models CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze + OPENAI_ONLY_FEATURES = %w[audio_transcription help_center_search].freeze class << self - def providers = CONFIG.fetch('providers') + def providers + Llm::Config.provider_options.transform_values { |display_name| { 'display_name' => display_name } } + end + def models = CONFIG.fetch('models') def features = CONFIG.fetch('features') def feature_keys = features.keys @@ -19,7 +23,7 @@ module Llm::Models end def models_for(feature) - (features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) } + (configured_models_for(feature) + provider_models_for(feature)).uniq end def valid_model_for?(feature, model_name) @@ -27,11 +31,11 @@ module Llm::Models end def model_config(model_name) - models[model_name.to_s] + models[model_name.to_s] || ruby_llm_model_config(model_name) end def provider_for(model_name) - model_config(model_name)&.dig('provider') + models.dig(model_name.to_s, 'provider') || ruby_llm_model(model_name)&.provider end def supported_provider?(provider) @@ -63,5 +67,40 @@ module Llm::Models default: feature['default'] } end + + private + + def configured_models_for(feature) + (features.dig(feature.to_s, 'models') || []).select { |model_name| supported_model?(model_name) } + end + + def provider_models_for(feature) + return [] if openai_only_feature?(feature) + + provider = Llm::Config.current_provider + return [] if provider == Llm::Config::DEFAULT_PROVIDER + + RubyLLM.models.by_provider(provider).chat_models.map(&:id) + end + + def openai_only_feature?(feature) + OPENAI_ONLY_FEATURES.include?(feature.to_s) + end + + def ruby_llm_model_config(model_name) + model = ruby_llm_model(model_name) + return unless model + + { + 'provider' => model.provider, + 'display_name' => model.name + } + end + + def ruby_llm_model(model_name) + RubyLLM.models.find(model_name.to_s) + rescue StandardError + nil + end end end diff --git a/lib/llm/provider_config.rb b/lib/llm/provider_config.rb new file mode 100644 index 000000000..1cf37a8f7 --- /dev/null +++ b/lib/llm/provider_config.rb @@ -0,0 +1,122 @@ +require 'ruby_llm' + +module Llm::ProviderConfig + PROVIDER_CONFIG_PREFIX = 'CAPTAIN_LLM'.freeze + + LEGACY_CONFIG_KEYS = { + openai_api_key: 'CAPTAIN_OPEN_AI_API_KEY', + openai_api_base: 'CAPTAIN_OPEN_AI_ENDPOINT', + anthropic_api_key: 'CAPTAIN_ANTHROPIC_API_KEY', + anthropic_api_base: 'CAPTAIN_ANTHROPIC_API_BASE', + gemini_api_key: 'CAPTAIN_GEMINI_API_KEY', + gemini_api_base: 'CAPTAIN_GEMINI_API_BASE' + }.freeze + + def ruby_llm_provider_supported?(provider) + RubyLLM::Provider.providers.key?(provider.to_s.to_sym) + end + + def provider_options + RubyLLM::Provider.providers.keys.map(&:to_s).sort.index_with do |provider| + ruby_llm_provider_name(provider) + end + end + + def provider_config_keys + (['CAPTAIN_LLM_PROVIDER'] + provider_config_options.values).uniq + end + + def provider_config_options + provider_options.keys.each_with_object({}) do |provider, result| + provider_configuration_options(provider).each do |option| + result[option] = installation_config_name(option) + end + end + end + + def current_provider + provider = InstallationConfig.find_by(name: 'CAPTAIN_LLM_PROVIDER')&.value.presence + return provider if provider_options.key?(provider) + + Llm::Config::DEFAULT_PROVIDER + end + + def api_key_for(provider) + provider_config_values(provider)[:"#{provider}_api_key"] + end + + def api_base_for(provider) + api_base = provider_config_values(provider)[:"#{provider}_api_base"].presence + return if api_base.blank? + + normalized_api_base(provider, api_base) + end + + def provider_configured?(provider) + requirements = provider_configuration_requirements(provider) + return false if requirements.blank? + + values = provider_config_values(provider) + requirements.all? { |requirement| values[requirement].present? } + end + + def openai_provider?(provider) + provider.to_s == Llm::Config::DEFAULT_PROVIDER + end + + def supports_tools_and_schema?(provider) + openai_provider?(provider) + end + + def provider_config_values(provider) + provider = provider.to_s + provider_configuration_options(provider).each_with_object({}) do |option, values| + value = installation_config_value(option).presence + value = normalized_api_base(provider, value) if option == :"#{provider}_api_base" && value.present? + values[option] = value if value.present? + end + end + + def configure_provider(config, provider:, config_values:) + options = provider_configuration_options(provider) + config_values.each do |option, value| + set_config_value(config, option, value) if value.present? && options.include?(option) + end + end + + private + + def ruby_llm_provider_name(provider) + RubyLLM::Provider.providers[provider.to_s.to_sym].name + end + + def provider_configuration_options(provider) + RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_options || [] + end + + def provider_configuration_requirements(provider) + RubyLLM::Provider.providers[provider.to_s.to_sym]&.configuration_requirements || [] + end + + def set_config_value(config, option, value) + setter = :"#{option}=" + config.public_send(setter, value) if config.respond_to?(setter) + end + + def installation_config_value(option) + InstallationConfig.find_by(name: installation_config_name(option))&.value + end + + def installation_config_name(option) + LEGACY_CONFIG_KEYS.fetch(option.to_sym) do + "#{PROVIDER_CONFIG_PREFIX}_#{option.to_s.upcase}" + end + end + + def normalized_api_base(provider, api_base) + endpoint = api_base.chomp('/').delete_suffix('/chat/completions') + return "#{endpoint}/v1" if openai_provider?(provider) && endpoint.exclude?('/v1') + + endpoint + end +end diff --git a/spec/controllers/super_admin/accounts_controller_spec.rb b/spec/controllers/super_admin/accounts_controller_spec.rb index 21f51ecd3..ba1f78d7a 100644 --- a/spec/controllers/super_admin/accounts_controller_spec.rb +++ b/spec/controllers/super_admin/accounts_controller_spec.rb @@ -66,6 +66,20 @@ RSpec.describe 'Super Admin accounts API', type: :request do expect(editor_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})") expect(editor_select.css('optgroup').map { |group| group['label'] }).to include('Default routing', 'OpenAI') end + + it 'includes selected RubyLLM provider models in the Captain model selectors', if: ChatwootApp.enterprise? do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') + sign_in(super_admin, scope: :super_admin) + + get "/super_admin/accounts/#{account.id}/edit" + + expect(response).to have_http_status(:success) + document = Nokogiri::HTML(response.body) + assistant_select = document.at_css('select[name="account[captain_models][assistant]"]') + + expect(assistant_select.css('optgroup').map { |group| group['label'] }).to include('OpenRouter') + expect(assistant_select.at_css('option[value="ai21/jamba-large-1.7"]')).to be_present + end end end diff --git a/spec/controllers/super_admin/app_config_controller_spec.rb b/spec/controllers/super_admin/app_config_controller_spec.rb index 4140cb6ae..448035f2c 100644 --- a/spec/controllers/super_admin/app_config_controller_spec.rb +++ b/spec/controllers/super_admin/app_config_controller_spec.rb @@ -61,11 +61,32 @@ RSpec.describe 'Super Admin Application Config API', type: :request do sign_in(super_admin, scope: :super_admin) post '/super_admin/app_config?config=captain', - params: { app_config: { CAPTAIN_ANTHROPIC_API_KEY: 'anthropic-key', CAPTAIN_GEMINI_API_KEY: 'gemini-key' } } + params: { + app_config: { + CAPTAIN_LLM_PROVIDER: 'openrouter', + CAPTAIN_ANTHROPIC_API_KEY: 'anthropic-key', + CAPTAIN_GEMINI_API_KEY: 'gemini-key', + CAPTAIN_LLM_OPENROUTER_API_KEY: 'openrouter-key' + } + } expect(response).to have_http_status(:found) + expect(GlobalConfig.get('CAPTAIN_LLM_PROVIDER')['CAPTAIN_LLM_PROVIDER']).to eq('openrouter') expect(GlobalConfig.get('CAPTAIN_ANTHROPIC_API_KEY')['CAPTAIN_ANTHROPIC_API_KEY']).to eq('anthropic-key') expect(GlobalConfig.get('CAPTAIN_GEMINI_API_KEY')['CAPTAIN_GEMINI_API_KEY']).to eq('gemini-key') + expect(GlobalConfig.get('CAPTAIN_LLM_OPENROUTER_API_KEY')['CAPTAIN_LLM_OPENROUTER_API_KEY']).to eq('openrouter-key') + end + + it 'renders provider selection and RubyLLM provider fields for Captain settings' do + sign_in(super_admin, scope: :super_admin) + + get '/super_admin/app_config?config=captain' + + expect(response).to have_http_status(:success) + document = Nokogiri::HTML(response.body) + + expect(document.at_css('select[name="app_config[CAPTAIN_LLM_PROVIDER]"] option[value="openrouter"]')).to be_present + expect(document.at_css('input[name="app_config[CAPTAIN_LLM_OPENROUTER_API_KEY]"]')).to be_present end end end diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 8b059acc3..a1d8952bd 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -10,7 +10,7 @@ RSpec.describe Captain::ConversationCompletionService do before do create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') - allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(Llm::Config).to receive(:with_provider).and_yield(mock_context) allow(mock_chat).to receive(:with_instructions) allow(mock_chat).to receive(:with_schema).and_return(mock_chat) allow(account).to receive(:feature_enabled?).and_call_original @@ -134,7 +134,9 @@ RSpec.describe Captain::ConversationCompletionService do end it 'uses the system API key instead of the account hook key' do - expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_yield(mock_context) + expect(Llm::Config).to receive(:with_provider) + .with(provider: 'openai', config_values: hash_including(openai_api_key: 'test-key')) + .and_yield(mock_context) allow(mock_chat).to receive(:ask).and_return( instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5) ) @@ -145,7 +147,7 @@ RSpec.describe Captain::ConversationCompletionService do it 'does not fall back to the account hook key when no system key exists' do InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil) - expect(Llm::Config).not_to receive(:with_api_key) + expect(Llm::Config).not_to receive(:with_provider) result = service.perform diff --git a/spec/enterprise/services/captain/llm/embedding_service_spec.rb b/spec/enterprise/services/captain/llm/embedding_service_spec.rb index 206ca147d..f089c2b8f 100644 --- a/spec/enterprise/services/captain/llm/embedding_service_spec.rb +++ b/spec/enterprise/services/captain/llm/embedding_service_spec.rb @@ -25,14 +25,36 @@ RSpec.describe Captain::Llm::EmbeddingService, type: :service do describe '#get_embedding' do let(:account) { create(:account) } + let(:mock_context) { instance_double(RubyLLM::Context) } let(:embedding_response) { double('embedding_response', vectors: [0.1, 0.2]) } # rubocop:disable RSpec/VerifiedDoubles + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + end + it 'sends the installation embedding model to RubyLLM' do configure_embedding_model('custom-embedding-model') - expect(RubyLLM).to receive(:embed).with('search text', model: 'custom-embedding-model').and_return(embedding_response) + expect(Llm::Config).to receive(:with_provider).with(provider: 'openai').and_yield(mock_context) + expect(mock_context).to receive(:embed).with( + 'search text', + model: 'custom-embedding-model', + provider: 'openai', + assume_model_exists: true + ).and_return(embedding_response) expect(described_class.new(account_id: account.id).get_embedding('search text')).to eq([0.1, 0.2]) end + + it 'requires OpenAI configuration even when another LLM provider is selected' do + InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').destroy + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') + create(:installation_config, name: 'CAPTAIN_LLM_OPENROUTER_API_KEY', value: 'openrouter-key') + + expect(Llm::Config).not_to receive(:with_provider) + + expect { described_class.new(account_id: account.id).get_embedding('search text') } + .to raise_error(described_class::EmbeddingsError, 'OpenAI configuration is required for embeddings.') + end end end diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index 5f023caea..3868ae1a4 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -120,7 +120,7 @@ RSpec.describe Captain::BaseTaskService do let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) } before do - allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(Llm::Config).to receive(:with_provider).and_yield(mock_context) allow(mock_chat).to receive(:with_instructions) allow(mock_chat).to receive(:ask).and_return(mock_response) end @@ -138,7 +138,7 @@ RSpec.describe Captain::BaseTaskService do end it 'does not make API call' do - expect(Llm::Config).not_to receive(:with_api_key) + expect(Llm::Config).not_to receive(:with_provider) service.send(:make_api_call, model: model, messages: messages) end end @@ -158,7 +158,7 @@ RSpec.describe Captain::BaseTaskService do end it 'does not make API call' do - expect(Llm::Config).not_to receive(:with_api_key) + expect(Llm::Config).not_to receive(:with_provider) service.send(:make_api_call, model: model, messages: messages) end end @@ -203,7 +203,9 @@ RSpec.describe Captain::BaseTaskService do create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_KEY', value: 'anthropic-key') account.update!(captain_models: { 'assistant' => 'claude-haiku-4.5' }) - expect(Llm::Config).to receive(:with_api_key).with('anthropic-key', provider: 'anthropic', api_base: nil).and_yield(mock_context) + expect(Llm::Config).to receive(:with_provider) + .with(provider: 'anthropic', config_values: hash_including(anthropic_api_key: 'anthropic-key')) + .and_yield(mock_context) expect(mock_context).to receive(:chat).with(model: 'claude-haiku-4.5', provider: 'anthropic', assume_model_exists: true).and_return(mock_chat) service.send(:make_api_call, feature: 'assistant', messages: messages) @@ -239,7 +241,7 @@ RSpec.describe Captain::BaseTaskService do let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) } before do - allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(Llm::Config).to receive(:with_provider).and_yield(mock_context) allow(mock_response).to receive(:input_tokens).and_return(10) allow(mock_response).to receive(:output_tokens).and_return(20) end @@ -295,7 +297,7 @@ RSpec.describe Captain::BaseTaskService do let(:exception_tracker) { instance_double(ChatwootExceptionTracker) } before do - allow(Llm::Config).to receive(:with_api_key).and_raise(error) + allow(Llm::Config).to receive(:with_provider).and_raise(error) allow(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker) allow(exception_tracker).to receive(:capture_exception) end @@ -318,7 +320,9 @@ RSpec.describe Captain::BaseTaskService do it 'tracks exceptions against the system key when an account hook exists' do create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' }) - expect(Llm::Config).to receive(:with_api_key).with('test-key', provider: 'openai', api_base: nil).and_raise(error) + expect(Llm::Config).to receive(:with_provider) + .with(provider: 'openai', config_values: hash_including(openai_api_key: 'test-key')) + .and_raise(error) expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker) expect(exception_tracker).to receive(:capture_exception) diff --git a/spec/lib/captain/label_suggestion_service_spec.rb b/spec/lib/captain/label_suggestion_service_spec.rb index c8d9ed6c7..1986d8d68 100644 --- a/spec/lib/captain/label_suggestion_service_spec.rb +++ b/spec/lib/captain/label_suggestion_service_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Captain::LabelSuggestionService do create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') label1 label2 - allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(Llm::Config).to receive(:with_provider).and_yield(mock_context) allow(mock_chat).to receive(:with_instructions) allow(mock_chat).to receive(:ask).and_return(mock_response) # Stub captain enabled check to allow specs to test base functionality diff --git a/spec/lib/captain/reply_suggestion_service_spec.rb b/spec/lib/captain/reply_suggestion_service_spec.rb index 608db43a2..3ec33d40a 100644 --- a/spec/lib/captain/reply_suggestion_service_spec.rb +++ b/spec/lib/captain/reply_suggestion_service_spec.rb @@ -18,7 +18,7 @@ RSpec.describe Captain::ReplySuggestionService do mock_chat = instance_double(RubyLLM::Chat) mock_context = instance_double(RubyLLM::Context, chat: mock_chat) - allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(Llm::Config).to receive(:with_provider).and_yield(mock_context) allow(mock_chat).to receive(:with_tool).and_return(mock_chat) allow(mock_chat).to receive(:on_end_message).and_return(mock_chat) allow(mock_chat).to receive(:with_instructions) { |msg| captured_messages << { role: 'system', content: msg } } diff --git a/spec/lib/captain/rewrite_service_spec.rb b/spec/lib/captain/rewrite_service_spec.rb index e4ef7efbf..994661612 100644 --- a/spec/lib/captain/rewrite_service_spec.rb +++ b/spec/lib/captain/rewrite_service_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Captain::RewriteService do before do create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') - allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(Llm::Config).to receive(:with_provider).and_yield(mock_context) allow(mock_chat).to receive(:with_instructions) allow(mock_chat).to receive(:ask).and_return(mock_response) # Stub captain enabled check to allow specs to test base functionality diff --git a/spec/lib/captain/summary_service_spec.rb b/spec/lib/captain/summary_service_spec.rb index def6daefe..192b598e9 100644 --- a/spec/lib/captain/summary_service_spec.rb +++ b/spec/lib/captain/summary_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe Captain::SummaryService do before do create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') - allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(Llm::Config).to receive(:with_provider).and_yield(mock_context) allow(mock_chat).to receive(:with_instructions) allow(mock_chat).to receive(:ask).and_return(mock_response) # Stub captain enabled check to allow specs to test base functionality diff --git a/spec/lib/integrations/llm_base_service_spec.rb b/spec/lib/integrations/llm_base_service_spec.rb index 8f5015650..1922f68b2 100644 --- a/spec/lib/integrations/llm_base_service_spec.rb +++ b/spec/lib/integrations/llm_base_service_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Integrations::LlmBaseService do describe '#make_api_call' do before do allow(service).to receive(:instrument_llm_call).and_yield - allow(Llm::Config).to receive(:with_api_key).and_raise(error) + allow(Llm::Config).to receive(:with_provider).and_raise(error) end it 'does not track exceptions for hook key failures' do diff --git a/spec/lib/llm/config_spec.rb b/spec/lib/llm/config_spec.rb index f602222a8..beac50349 100644 --- a/spec/lib/llm/config_spec.rb +++ b/spec/lib/llm/config_spec.rb @@ -8,11 +8,29 @@ RSpec.describe Llm::Config do expect(described_class.provider_options).to include( 'openai' => 'OpenAI', 'anthropic' => 'Anthropic', - 'gemini' => 'Gemini' + 'gemini' => 'Gemini', + 'openrouter' => 'OpenRouter' ) end end + describe '.provider_config_keys' do + it 'includes dynamic RubyLLM provider credential keys' do + expect(described_class.provider_config_keys).to include( + 'CAPTAIN_LLM_PROVIDER', + 'CAPTAIN_LLM_OPENROUTER_API_KEY' + ) + end + end + + describe '.provider_configured?' do + it 'uses dynamic provider requirements' do + create(:installation_config, name: 'CAPTAIN_LLM_OPENROUTER_API_KEY', value: 'openrouter-key') + + expect(described_class.provider_configured?('openrouter')).to be true + end + end + describe '.api_base_for' do it 'normalizes OpenAI-compatible endpoints to the v1 base' do create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://proxy.example.com/chat/completions') diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb index 5aa671141..2d5f28971 100644 --- a/spec/lib/llm/models_spec.rb +++ b/spec/lib/llm/models_spec.rb @@ -48,6 +48,24 @@ RSpec.describe Llm::Models do expect(described_class.models_for('assistant')).not_to include('claude-haiku-4.5') end + + it 'adds selected RubyLLM provider chat models for chat features' do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') + + expect(described_class.models_for('assistant')).to include('ai21/jamba-large-1.7') + end + + it 'does not add selected chat-provider models to OpenAI-only features' do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') + + expect(described_class.models_for('help_center_search')).not_to include('ai21/jamba-large-1.7') + end + end + + describe '.provider_for' do + it 'uses RubyLLM model metadata for provider models outside llm.yml' do + expect(described_class.provider_for('ai21/jamba-large-1.7')).to eq('openrouter') + end end describe '.feature_config' do diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 56bd41f7c..d020c227f 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -386,6 +386,13 @@ RSpec.describe Account do expect(account).to be_valid end + it 'accepts models from the selected RubyLLM provider' do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') + account.captain_models = { 'assistant' => 'ai21/jamba-large-1.7' } + + expect(account).to be_valid + end + it 'rejects unknown feature keys' do account.captain_models = { 'unknown_feature' => 'gpt-4.1' }