From 4fd1a5c2efa77ac32c6ffb2a8e36f194c5443e2a Mon Sep 17 00:00:00 2001 From: aakashb95 Date: Fri, 26 Jun 2026 14:11:26 +0530 Subject: [PATCH] fix(captain): scope model routing to app provider --- .../super_admin/app_configs_controller.rb | 47 +++++++++++--- app/models/installation_config.rb | 1 + .../super_admin/app_configs/show.html.erb | 9 +++ config/installation_config.yml | 9 ++- enterprise/app/models/concerns/agentable.rb | 8 +-- .../app/services/llm/base_ai_service.rb | 6 +- .../conversation_completion_service.rb | 2 +- lib/llm/models.rb | 45 ++++++------- lib/llm/provider_config.rb | 17 +++-- lib/llm/provider_model_catalog.rb | 63 +++++++++++++++++++ .../super_admin/app_config_controller_spec.rb | 14 +++-- .../models/concerns/agentable_spec.rb | 8 +-- .../services/llm/base_ai_service_spec.rb | 10 +-- spec/lib/captain/base_task_service_spec.rb | 2 + spec/lib/llm/config_spec.rb | 6 ++ spec/lib/llm/models_spec.rb | 17 +++++ spec/models/account_spec.rb | 8 +++ 17 files changed, 203 insertions(+), 69 deletions(-) create mode 100644 lib/llm/provider_model_catalog.rb diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 67d27a849..b373536f0 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -21,7 +21,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController next unless @allowed_configs.include?(key) i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) - i.value = value + i.value = app_config_value(key, value) errors.concat(i.errors.full_messages) unless i.save end @@ -73,31 +73,62 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController def restart_required_config_saved? saved_keys = params.fetch('app_config', {}).keys - saved_keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) || saved_keys.intersect?(Llm::Config.provider_config_keys) + saved_keys.intersect?(InstallationConfig::RESTART_REQUIRED_CONFIG_KEYS) || + saved_keys.intersect?(Llm::Config.provider_config_keys(captain_provider)) end def captain_config_options - (Llm::Config.provider_config_keys + %w[CAPTAIN_OPEN_AI_MODEL]).uniq + Llm::Config.provider_config_keys(captain_provider) end def populate_captain_config_metadata - @app_config['CAPTAIN_LLM_PROVIDER'] ||= Llm::Config.current_provider + @app_config[Llm::ProviderConfig::PROVIDER_CONFIG_KEY] = captain_provider - @installation_configs['CAPTAIN_LLM_PROVIDER'] = { + populate_provider_metadata + populate_model_metadata + populate_provider_field_metadata + end + + def populate_provider_metadata + @installation_configs[Llm::ProviderConfig::PROVIDER_CONFIG_KEY] = { 'display_title' => 'LLM Provider', - 'description' => 'Provider used to populate Captain model override dropdowns.', + 'description' => 'Provider used by Captain AI for chat and generation features.', 'type' => 'select', 'options' => Llm::Config.provider_options } + end - Llm::Config.provider_config_options.each do |option, config_key| + def populate_model_metadata + @installation_configs[Llm::ProviderConfig::MODEL_CONFIG_KEY] = { + 'display_title' => 'LLM Model', + 'description' => "Default #{Llm::Config.provider_options[captain_provider]} model used by Captain AI.", + 'type' => 'select', + 'options' => Llm::Models.provider_default_model_options(captain_provider) + } + end + + def populate_provider_field_metadata + Llm::Config.provider_config_options(captain_provider).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' + 'type' => option.to_s.end_with?('api_key', 'secret_key', 'session_token', 'service_account_key', 'auth_token') ? 'secret' : 'text', + 'provider' => captain_provider } end end + + def captain_provider + requested_provider = params.dig(:app_config, Llm::ProviderConfig::PROVIDER_CONFIG_KEY).presence || params[:provider].presence + return requested_provider if Llm::Config.provider_options.key?(requested_provider) + + Llm::Config.current_provider + end + + def app_config_value(key, value) + return value unless @config == 'captain' && key == Llm::ProviderConfig::MODEL_CONFIG_KEY + return value if Llm::Models.provider_default_model_options(captain_provider).key?(value) + end end SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController') diff --git a/app/models/installation_config.rb b/app/models/installation_config.rb index 36eada464..6bbc0dc07 100644 --- a/app/models/installation_config.rb +++ b/app/models/installation_config.rb @@ -20,6 +20,7 @@ class InstallationConfig < ApplicationRecord CAPTAIN_OPEN_AI_ENDPOINT CAPTAIN_OPEN_AI_MODEL CAPTAIN_LLM_PROVIDER + CAPTAIN_LLM_MODEL CAPTAIN_ANTHROPIC_API_KEY CAPTAIN_ANTHROPIC_API_BASE CAPTAIN_GEMINI_API_KEY diff --git a/app/views/super_admin/app_configs/show.html.erb b/app/views/super_admin/app_configs/show.html.erb index 8bf513597..f361cbdfa 100644 --- a/app/views/super_admin/app_configs/show.html.erb +++ b/app/views/super_admin/app_configs/show.html.erb @@ -95,6 +95,15 @@ } }); }); + + const captainProviderSelect = document.querySelector('select[name="app_config[CAPTAIN_LLM_PROVIDER]"]'); + if (captainProviderSelect) { + captainProviderSelect.addEventListener('change', () => { + const url = new URL(window.location.href); + url.searchParams.set('provider', captainProviderSelect.value); + window.location.href = url.toString(); + }); + } }); <% end %> diff --git a/config/installation_config.yml b/config/installation_config.yml index b16a18e2b..fad4c81f0 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -189,7 +189,7 @@ type: secret - name: CAPTAIN_OPEN_AI_MODEL display_title: 'OpenAI Model' - description: 'The OpenAI model configured for use in Captain AI. Default: gpt-4.1-mini' + description: 'Legacy OpenAI model setting for Captain AI. Use LLM Model for provider-aware defaults.' locked: false - name: CAPTAIN_OPEN_AI_ENDPOINT display_title: 'OpenAI API Endpoint (optional)' @@ -197,10 +197,15 @@ locked: false - name: CAPTAIN_LLM_PROVIDER display_title: 'LLM Provider' - description: 'Provider used to populate Captain model override dropdowns.' + description: 'Provider used by Captain AI for chat and generation features.' value: openai locked: false type: select +- name: CAPTAIN_LLM_MODEL + display_title: 'LLM Model' + description: 'Default model used by Captain AI for chat and generation features.' + 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/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb index 72f876cfc..ff267cf38 100644 --- a/enterprise/app/models/concerns/agentable.rb +++ b/enterprise/app/models/concerns/agentable.rb @@ -44,13 +44,7 @@ module Concerns::Agentable def agent_model route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account) - return route[:model] if route[:source] == :account_override - - installation_model.presence || route[:model] - end - - def installation_model - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value + route[:model] end def agent_response_schema diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb index a77f16aef..cc7222a05 100644 --- a/enterprise/app/services/llm/base_ai_service.rb +++ b/enterprise/app/services/llm/base_ai_service.rb @@ -40,7 +40,7 @@ class Llm::BaseAiService return setup_provider(route) end - @model = @fallback_model.presence || installation_model.presence || route&.dig(:model) || DEFAULT_MODEL + @model = @fallback_model.presence || route&.dig(:model) || Llm::Models.installation_model || DEFAULT_MODEL setup_provider(route) end @@ -54,10 +54,6 @@ class Llm::BaseAiService route&.dig(:source) == :account_override end - def installation_model - InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value - end - def setup_provider(route) @provider = provider_for_model(@model, route&.dig(:provider)) end diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb index 7902e0f1b..fccdf7c8b 100644 --- a/enterprise/lib/captain/conversation_completion_service.rb +++ b/enterprise/lib/captain/conversation_completion_service.rb @@ -16,7 +16,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService return default_incomplete_response('No messages found') if content.blank? response = make_api_call( - model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL, + model: Llm::Models.installation_model || GPT_MODEL, messages: [ { role: 'system', content: prompt_from_file('conversation_completion') }, { role: 'user', content: content } diff --git a/lib/llm/models.rb b/lib/llm/models.rb index c34dae74d..fae5ab3ff 100644 --- a/lib/llm/models.rb +++ b/lib/llm/models.rb @@ -1,4 +1,8 @@ +require_relative 'provider_model_catalog' + module Llm::Models + extend Llm::ProviderModelCatalog + CONFIG = YAML.load_file(Rails.root.join('config/llm.yml')).freeze OPENAI_ONLY_FEATURES = %w[audio_transcription help_center_search].freeze @@ -16,18 +20,23 @@ module Llm::Models end def default_model_for(feature) - default_model = features.dig(feature.to_s, 'default') - return default_model if supported_model?(default_model) + installation_default = installation_model + return installation_default if valid_model_for?(feature, installation_default) + + feature_default = features.dig(feature.to_s, 'default') + return feature_default if valid_model_for?(feature, feature_default) models_for(feature).first end - def models_for(feature) - (configured_models_for(feature) + provider_models_for(feature)).uniq + def models_for(feature, provider: provider_for_feature(feature)) + (configured_models_for(feature, provider: provider) + provider_models_for(feature, provider: provider)).uniq end - def valid_model_for?(feature, model_name) - models_for(feature).include?(model_name.to_s) + def valid_model_for?(feature, model_name, provider: provider_for_feature(feature)) + return false if model_name.blank? + + models_for(feature, provider: provider).include?(model_name.to_s) end def model_config(model_name) @@ -53,8 +62,10 @@ module Llm::Models feature = features[feature_key.to_s] return nil unless feature + provider = provider_for_feature(feature_key) + { - models: models_for(feature_key).map do |model_name| + models: models_for(feature_key, provider: provider).map do |model_name| model = model_config(model_name) { id: model_name, @@ -64,29 +75,13 @@ module Llm::Models credit_multiplier: model['credit_multiplier'] } end, - default: feature['default'] + default: default_model_for(feature_key), + provider: provider } 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 diff --git a/lib/llm/provider_config.rb b/lib/llm/provider_config.rb index 1cf37a8f7..7acd93c92 100644 --- a/lib/llm/provider_config.rb +++ b/lib/llm/provider_config.rb @@ -2,6 +2,9 @@ require 'ruby_llm' module Llm::ProviderConfig PROVIDER_CONFIG_PREFIX = 'CAPTAIN_LLM'.freeze + PROVIDER_CONFIG_KEY = 'CAPTAIN_LLM_PROVIDER'.freeze + MODEL_CONFIG_KEY = 'CAPTAIN_LLM_MODEL'.freeze + LEGACY_OPENAI_MODEL_CONFIG_KEY = 'CAPTAIN_OPEN_AI_MODEL'.freeze LEGACY_CONFIG_KEYS = { openai_api_key: 'CAPTAIN_OPEN_AI_API_KEY', @@ -22,20 +25,22 @@ module Llm::ProviderConfig end end - def provider_config_keys - (['CAPTAIN_LLM_PROVIDER'] + provider_config_options.values).uniq + def provider_config_keys(provider = nil) + ([PROVIDER_CONFIG_KEY, MODEL_CONFIG_KEY] + provider_config_options(provider).values).uniq end - def provider_config_options - provider_options.keys.each_with_object({}) do |provider, result| - provider_configuration_options(provider).each do |option| + def provider_config_options(provider = nil) + providers = provider.present? ? [provider.to_s] : provider_options.keys + + providers.each_with_object({}) do |provider_name, result| + provider_configuration_options(provider_name).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 + provider = InstallationConfig.find_by(name: PROVIDER_CONFIG_KEY)&.value.presence return provider if provider_options.key?(provider) Llm::Config::DEFAULT_PROVIDER diff --git a/lib/llm/provider_model_catalog.rb b/lib/llm/provider_model_catalog.rb new file mode 100644 index 000000000..f431f8eaa --- /dev/null +++ b/lib/llm/provider_model_catalog.rb @@ -0,0 +1,63 @@ +module Llm::ProviderModelCatalog + def installation_model(provider: provider_for_feature(nil)) + model = InstallationConfig.find_by(name: Llm::ProviderConfig::MODEL_CONFIG_KEY)&.value.presence + return model if model_provider?(model, provider) + + legacy_model = InstallationConfig.find_by(name: Llm::ProviderConfig::LEGACY_OPENAI_MODEL_CONFIG_KEY)&.value.presence + return legacy_model if provider == Llm::Config::DEFAULT_PROVIDER && model_provider?(legacy_model, provider) + end + + def provider_default_model_options(provider = Llm::Config.current_provider) + models_for_provider(provider).index_with do |model_name| + model = model_config(model_name) + model['display_name'] || model_name + end + end + + private + + def provider_for_feature(feature) + return Llm::Config::DEFAULT_PROVIDER if openai_only_feature?(feature) + + Llm::Config.current_provider + end + + def configured_models_for(feature, provider:) + (features.dig(feature.to_s, 'models') || []).select do |model_name| + supported_model?(model_name) && model_provider?(model_name, provider) + end + end + + def provider_models_for(feature, provider:) + return [] if openai_only_feature?(feature) + return [] if provider == Llm::Config::DEFAULT_PROVIDER + + models_for_provider(provider) + end + + def models_for_provider(provider) + configured_provider_models = models.filter_map do |model_name, config| + model_name if config['provider'] == provider.to_s && chat_model?(model_name) + end + + (configured_provider_models + ruby_llm_provider_models(provider)).uniq + end + + def ruby_llm_provider_models(provider) + return [] if provider.to_s == Llm::Config::DEFAULT_PROVIDER + + RubyLLM.models.by_provider(provider.to_s).chat_models.map(&:id) + end + + def chat_model?(model_name) + Llm::Models::OPENAI_ONLY_FEATURES.none? { |feature| features.dig(feature, 'models')&.include?(model_name) } + end + + def model_provider?(model_name, provider) + provider_for(model_name) == provider.to_s + end + + def openai_only_feature?(feature) + Llm::Models::OPENAI_ONLY_FEATURES.include?(feature.to_s) + 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 448035f2c..8dcbdf37e 100644 --- a/spec/controllers/super_admin/app_config_controller_spec.rb +++ b/spec/controllers/super_admin/app_config_controller_spec.rb @@ -64,29 +64,31 @@ RSpec.describe 'Super Admin Application Config API', type: :request do params: { app_config: { CAPTAIN_LLM_PROVIDER: 'openrouter', - CAPTAIN_ANTHROPIC_API_KEY: 'anthropic-key', - CAPTAIN_GEMINI_API_KEY: 'gemini-key', + CAPTAIN_LLM_MODEL: 'ai21/jamba-large-1.7', 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_MODEL')['CAPTAIN_LLM_MODEL']).to eq('ai21/jamba-large-1.7') 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' + get '/super_admin/app_config?config=captain&provider=openrouter' 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 + provider_option = document.at_css('select[name="app_config[CAPTAIN_LLM_PROVIDER]"] option[value="openrouter"]') + expect(provider_option).to be_present + expect(provider_option['selected']).to eq('selected') + expect(document.at_css('select[name="app_config[CAPTAIN_LLM_MODEL]"] option[value="ai21/jamba-large-1.7"]')).to be_present expect(document.at_css('input[name="app_config[CAPTAIN_LLM_OPENROUTER_API_KEY]"]')).to be_present + expect(document.at_css('input[name="app_config[CAPTAIN_ANTHROPIC_API_KEY]"]')).to be_blank end end end diff --git a/spec/enterprise/models/concerns/agentable_spec.rb b/spec/enterprise/models/concerns/agentable_spec.rb index 996b92d45..40a890ea8 100644 --- a/spec/enterprise/models/concerns/agentable_spec.rb +++ b/spec/enterprise/models/concerns/agentable_spec.rb @@ -37,7 +37,7 @@ RSpec.describe Concerns::Agentable do let(:mock_agents_agent) { instance_double(Agents::Agent) } before do - InstallationConfig.where(name: 'CAPTAIN_OPEN_AI_MODEL').destroy_all + InstallationConfig.where(name: %w[CAPTAIN_OPEN_AI_MODEL CAPTAIN_LLM_MODEL]).destroy_all allow(Agents::Agent).to receive(:new).and_return(mock_agents_agent) allow(Captain::PromptRenderer).to receive(:render).and_return('rendered_template') end @@ -167,16 +167,16 @@ RSpec.describe Concerns::Agentable do end it 'returns account override model when present' do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini') account.update!(captain_models: { 'assistant' => 'gpt-5.2' }) expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2') end it 'returns the installation model when account override is absent' do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini') - expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano') + expect(dummy_instance.send(:agent_model)).to eq('gpt-5-mini') end it 'returns the assistant feature default model when account is nil' do diff --git a/spec/enterprise/services/llm/base_ai_service_spec.rb b/spec/enterprise/services/llm/base_ai_service_spec.rb index f18752485..a5f7a989b 100644 --- a/spec/enterprise/services/llm/base_ai_service_spec.rb +++ b/spec/enterprise/services/llm/base_ai_service_spec.rb @@ -6,28 +6,28 @@ RSpec.describe Llm::BaseAiService do let(:account) { create(:account) } before do - InstallationConfig.where(name: %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL]).destroy_all + InstallationConfig.where(name: %w[CAPTAIN_OPEN_AI_API_KEY CAPTAIN_OPEN_AI_MODEL CAPTAIN_LLM_MODEL]).destroy_all create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') end describe '#initialize' do it 'uses the installation model when no feature is provided' do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-4.1-nano') expect(described_class.new.model).to eq('gpt-4.1-nano') end it 'uses the account override when feature context is provided' do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini') account.update!(captain_models: { 'assistant' => 'gpt-5.2' }) expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2') end it 'uses the installation model when feature context has no account override' do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano') + create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini') - expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano') + expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5-mini') end it 'uses the feature default when feature context has no account override or installation model' do diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb index 3868ae1a4..c9a08b73e 100644 --- a/spec/lib/captain/base_task_service_spec.rb +++ b/spec/lib/captain/base_task_service_spec.rb @@ -200,6 +200,7 @@ RSpec.describe Captain::BaseTaskService do end it 'uses the model provider for account overrides' do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'anthropic') create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_KEY', value: 'anthropic-key') account.update!(captain_models: { 'assistant' => 'claude-haiku-4.5' }) @@ -212,6 +213,7 @@ RSpec.describe Captain::BaseTaskService do end it 'does not attach schemas or tools for non-OpenAI providers' do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'anthropic') create(:installation_config, name: 'CAPTAIN_ANTHROPIC_API_KEY', value: 'anthropic-key') account.update!(captain_models: { 'assistant' => 'claude-haiku-4.5' }) diff --git a/spec/lib/llm/config_spec.rb b/spec/lib/llm/config_spec.rb index beac50349..16a6e004b 100644 --- a/spec/lib/llm/config_spec.rb +++ b/spec/lib/llm/config_spec.rb @@ -18,9 +18,15 @@ RSpec.describe Llm::Config do it 'includes dynamic RubyLLM provider credential keys' do expect(described_class.provider_config_keys).to include( 'CAPTAIN_LLM_PROVIDER', + 'CAPTAIN_LLM_MODEL', 'CAPTAIN_LLM_OPENROUTER_API_KEY' ) end + + it 'can be scoped to a single provider' do + expect(described_class.provider_config_keys('openrouter')).to include('CAPTAIN_LLM_OPENROUTER_API_KEY') + expect(described_class.provider_config_keys('openrouter')).not_to include('CAPTAIN_ANTHROPIC_API_KEY') + end end describe '.provider_configured?' do diff --git a/spec/lib/llm/models_spec.rb b/spec/lib/llm/models_spec.rb index 2d5f28971..d86b3e8ce 100644 --- a/spec/lib/llm/models_spec.rb +++ b/spec/lib/llm/models_spec.rb @@ -53,12 +53,29 @@ RSpec.describe Llm::Models do create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') expect(described_class.models_for('assistant')).to include('ai21/jamba-large-1.7') + expect(described_class.models_for('assistant')).not_to include('gpt-4.1') 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') + expect(described_class.models_for('help_center_search')).to include('text-embedding-3-small') + end + end + + describe '.default_model_for' do + it 'uses the installation model when it is valid for the feature and active provider' do + create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini') + + expect(described_class.default_model_for('assistant')).to eq('gpt-5-mini') + end + + it 'ignores installation models from another provider' do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') + create(:installation_config, name: 'CAPTAIN_LLM_MODEL', value: 'gpt-5-mini') + + expect(described_class.default_model_for('assistant')).to eq('ai21/jamba-large-1.7') end end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index d020c227f..7018fdeaa 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -393,6 +393,14 @@ RSpec.describe Account do expect(account).to be_valid end + it 'rejects models outside the selected LLM provider' do + create(:installation_config, name: 'CAPTAIN_LLM_PROVIDER', value: 'openrouter') + account.captain_models = { 'assistant' => 'gpt-5.2' } + + expect(account).not_to be_valid + expect(account.errors[:captain_models].first).to include('not a valid model for assistant') + end + it 'rejects unknown feature keys' do account.captain_models = { 'unknown_feature' => 'gpt-4.1' }