fix(captain): scope model routing to app provider

This commit is contained in:
aakashb95
2026-06-26 14:11:26 +05:30
parent 7793e4645f
commit 4fd1a5c2ef
17 changed files with 203 additions and 69 deletions
@@ -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')
+1
View File
@@ -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
@@ -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();
});
}
});
</script>
<% end %>
+7 -2
View File
@@ -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.'
+1 -7
View File
@@ -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
@@ -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
@@ -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 }
+20 -25
View File
@@ -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
+11 -6
View File
@@ -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
+63
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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' })
+6
View File
@@ -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
+17
View File
@@ -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
+8
View File
@@ -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' }