refactor(captain): route conversation completion by feature (#15317)

Conversation completion evaluations now use a dedicated internal LLM
feature with GPT 4.1 as the default. The internal route keeps the
completion model separate from the installation wide Captain model
override and from the assistant route, which can use GPT 5.2 for Captain
V2 accounts. Evaluations continue to use the installation API key and do
not consume Captain response credits.

## What changed

Added an internal `conversation_completion` feature to the LLM model
config and excluded internal features from account preferences, the
Captain settings API, and Super Admin model overrides.

Updated `Captain::ConversationCompletionService` to resolve its model
through `Llm::FeatureRouter`.

Added focused service and request coverage for model routing and
settings visibility.
This commit is contained in:
Aakash Bakhle
2026-08-13 13:54:35 +05:30
committed by GitHub
parent eff7ce2898
commit 13de83d1dc
7 changed files with 66 additions and 4 deletions

View File

@@ -69,6 +69,10 @@ models:
credit_multiplier: 1
features:
conversation_completion:
models: [gpt-4.1]
default: gpt-4.1
internal: true
editor:
models:
[

View File

@@ -16,7 +16,8 @@ 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,
feature: 'conversation_completion',
model: self_hosted_model_override,
messages: [
{ role: 'system', content: prompt_from_file('conversation_completion') },
{ role: 'user', content: content }
@@ -31,6 +32,12 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
private
def self_hosted_model_override
return unless ChatwootApp.self_hosted_enterprise?
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence
end
def prompt_from_file(file_name)
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
end

View File

@@ -24,6 +24,8 @@ module Llm::FeatureRouter
private
def account_model_override(account, feature_key)
return if Llm::Models.internal_feature?(feature_key)
model = account&.captain_models&.[](feature_key).presence
return unless model
return model if Llm::Models.valid_model_for?(feature_key, model)

View File

@@ -5,7 +5,11 @@ module Llm::Models
def providers = CONFIG.fetch('providers')
def models = CONFIG.fetch('models')
def features = CONFIG.fetch('features')
def feature_keys = features.keys
def feature_keys = features.reject { |_key, config| config['internal'] }.keys
def internal_feature?(feature)
features.dig(feature.to_s, 'internal') == true
end
def feature?(feature)
features.key?(feature.to_s)

View File

@@ -31,6 +31,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(json_response).to have_key(:providers)
expect(json_response).to have_key(:models)
expect(json_response).to have_key(:features)
expect(json_response[:features]).not_to have_key(:conversation_completion)
end
end
@@ -150,14 +151,21 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
expect(account.reload.captain_models['editor']).to eq('gpt-4.1-mini')
end
it 'does not persist unknown captain model feature keys' do
it 'does not persist unknown or internal captain model feature keys' do
put "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
params: { captain_models: { editor: 'gpt-4.1-mini', unknown_feature: 'gpt-4.1' } },
params: {
captain_models: {
editor: 'gpt-4.1-mini',
unknown_feature: 'gpt-4.1',
conversation_completion: 'gpt-4.1'
}
},
as: :json
expect(response).to have_http_status(:success)
expect(account.reload.captain_models).to eq('editor' => 'gpt-4.1-mini')
expect(json_response[:features]).not_to have_key(:conversation_completion)
end
it 'rejects invalid captain model values for the feature' do

View File

@@ -57,6 +57,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
Llm::Models.feature_keys.each do |feature_key|
expect(response.body).to include("account[captain_models][#{feature_key}]")
end
expect(response.body).not_to include('account[captain_models][conversation_completion]')
document = Nokogiri::HTML(response.body)
editor_select = document.at_css('select[name="account[captain_models][editor]"]')

View File

@@ -19,6 +19,42 @@ RSpec.describe Captain::ConversationCompletionService do
end
describe '#perform' do
describe 'model routing' do
let(:mock_response) do
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
end
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'uses the internal GPT-4.1 route on Chatwoot Cloud' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_MODEL').update!(value: 'gpt-5.1')
account.enable_features!('captain_integration_v2')
allow(mock_context).to receive(:chat).with(model: 'gpt-4.1').and_return(mock_chat)
expect(service.perform).to include(complete: true)
end
it 'uses the installation model on self-hosted Enterprise' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_MODEL').update!(value: 'gpt-5.1')
allow(mock_context).to receive(:chat).with(model: 'gpt-5.1').and_return(mock_chat)
expect(service.perform).to include(complete: true)
end
it 'falls back to the internal GPT-4.1 route when the self-hosted installation model is blank' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_MODEL').update!(value: '')
allow(mock_context).to receive(:chat).with(model: 'gpt-4.1').and_return(mock_chat)
expect(service.perform).to include(complete: true)
end
end
context 'when conversation is complete' do
let(:mock_response) do
instance_double(