feat: default new accounts to captain v2 (#14917)

# Pull Request Template

## Description

defaults new accounts to captain v2

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Aakash Bakhle
2026-07-07 16:21:51 +05:30
committed by GitHub
parent eab859cd04
commit 83dda621c5
18 changed files with 243 additions and 20 deletions
@@ -66,6 +66,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -74,4 +75,10 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -29,6 +29,8 @@ class CaptainModelOverridesField < Administrate::Field::Base
end
def default_model_id(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && resource.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
+1 -1
View File
@@ -47,7 +47,7 @@ module Concerns::Agentable
def agent_model
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
return route[:model] if route[:source] == :account_override
return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
installation_model.presence || route[:model]
end
@@ -1,4 +1,9 @@
module Enterprise::Account
# Transitional marker for the Captain V1 to V2 rollout. New cloud accounts get
# this marker so plan reconciliation can enable V2 for them without upgrading
# existing paid accounts. Remove once every account is migrated to V2.
CAPTAIN_V2_DEFAULT_ELIGIBLE = 'captain_v2_default_eligible'.freeze
class << self
def captain_document_sync_intervals
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
@@ -94,6 +99,15 @@ module Enterprise::Account
private
def enable_default_features
super
if ChatwootApp.self_hosted_enterprise?
enable_features('captain_integration', 'captain_integration_v2')
elsif ChatwootApp.chatwoot_cloud?
internal_attributes[CAPTAIN_V2_DEFAULT_ELIGIBLE] = true
end
end
def sync_assignment_features
if feature_enabled?('assignment_v2')
# Enable advanced_assignment for Business/Enterprise plans
@@ -36,7 +36,9 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
account.disable_features('captain_integration_v2') if default_plan?
account.enable_features(*current_plan_features)
account.enable_features('captain_integration_v2') if captain_v2_default_eligible?
account.enable_features(*manually_managed_features)
account.save!
end
@@ -69,4 +71,8 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def manually_managed_features
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
end
def captain_v2_default_eligible?
!default_plan? && account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE] == true
end
end
@@ -34,7 +34,7 @@ class Llm::BaseAiService
def setup_model
route = feature_route
return @model = route[:model] if account_override_route?(route)
return @model = route[:model] if account_override_route?(route) || captain_v2_assistant?
@model = @fallback_model.presence || installation_model.presence || route&.dig(:model) || DEFAULT_MODEL
end
@@ -49,6 +49,10 @@ class Llm::BaseAiService
route&.dig(:source) == :account_override
end
def captain_v2_assistant?
@llm_feature.to_s == 'assistant' && @llm_account&.feature_enabled?('captain_integration_v2')
end
def installation_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
end
+1
View File
@@ -4,6 +4,7 @@
- sla
- custom_roles
- captain_integration
- captain_integration_v2
- captain_document_auto_sync
- csat_review_notes
- conversation_required_attributes
+10
View File
@@ -1,6 +1,8 @@
module Llm::FeatureRouter
class UnknownFeatureError < StandardError; end
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
class << self
def resolve(feature:, account: nil)
feature_key = feature.to_s
@@ -8,6 +10,7 @@ module Llm::FeatureRouter
model = account_model_override(account, feature_key)
source = model.present? ? :account_override : :default
model ||= captain_v2_assistant_model(account, feature_key)
model ||= Llm::Models.default_model_for(feature_key)
{
@@ -25,5 +28,12 @@ module Llm::FeatureRouter
return unless model
return model if Llm::Models.valid_model_for?(feature_key, model)
end
def captain_v2_assistant_model(account, feature_key)
return unless feature_key == 'assistant'
return unless account&.feature_enabled?('captain_integration_v2')
CAPTAIN_V2_ASSISTANT_MODEL
end
end
end
@@ -67,6 +67,50 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
source: 'default'
)
end
it 'returns the assistant YAML default for V1 accounts' do
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::Models.default_model_for('assistant'),
selected: Llm::Models.default_model_for('assistant'),
source: 'default'
)
end
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
account.enable_features!('captain_integration_v2')
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
selected: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
source: 'default'
)
end
it 'keeps the V2 assistant default when an account override is selected' do
account.enable_features!('captain_integration_v2')
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
selected: 'gpt-5.1',
source: 'account_override'
)
end
end
end
@@ -65,6 +65,21 @@ 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})")
end
it 'shows the Captain V2 assistant default in the model selector', if: ChatwootApp.enterprise? do
account.enable_features!('captain_integration_v2')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/edit"
document = Nokogiri::HTML(response.body)
assistant_select = document.at_css('select[name="account[captain_models][assistant]"]')
default_model_id = Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL
default_model = Llm::Models.model_config(default_model_id)['display_name']
expect(response).to have_http_status(:success)
expect(assistant_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
end
end
@@ -97,6 +112,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
it 'rejects invalid Captain model overrides' do
sign_in(super_admin, scope: :super_admin)
existing_captain_models = account.captain_models
patch "/super_admin/accounts/#{account.id}",
params: {
@@ -112,7 +128,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('not a valid model for label_suggestion')
expect(account.reload.captain_models).to be_nil
expect(account.reload.captain_models).to eq(existing_captain_models)
end
end
end
+31
View File
@@ -222,6 +222,37 @@ RSpec.describe Account, type: :model do
end
end
describe 'default features' do
before do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
value: Featurable::FEATURE_LIST,
locked: true
)
end
it 'enables Captain V2 for new self-hosted enterprise accounts' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
account = create(:account)
expect(account).to be_feature_enabled('captain_integration')
expect(account).to be_feature_enabled('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_models).to be_nil
end
it 'marks new cloud accounts as eligible for the Captain V2 paid-plan default' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account = create(:account)
expect(account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE]).to be true
expect(account).not_to be_feature_enabled('captain_integration')
expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
describe 'captain document sync cadence' do
let(:account) { create(:account) }
@@ -179,6 +179,14 @@ RSpec.describe Concerns::Agentable do
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano')
end
it 'returns the Captain V2 default when Captain V2 is enabled' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
it 'returns the assistant feature default model when account is nil' do
agent = dummy_class.new(account: nil)
@@ -175,6 +175,7 @@ describe Enterprise::Billing::HandleStripeEventService do
described_class::STARTUP_PLAN_FEATURES.each do |feature|
account.enable_features(feature)
end
account.enable_features('captain_integration_v2')
account.enable_features(*described_class::BUSINESS_PLAN_FEATURES)
account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES)
account.save!
@@ -193,6 +194,7 @@ describe Enterprise::Billing::HandleStripeEventService do
all_features.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
@@ -218,6 +220,29 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account).not_to be_feature_enabled(feature)
end
end
it 'does not enable Captain V2 for existing paid accounts during reconciliation' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
expect(account.reload).not_to be_feature_enabled('captain_integration_v2')
end
it 'enables Captain V2 for new cloud accounts marked as default eligible' do
account.update!(
internal_attributes: account.internal_attributes.merge(
Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE => true
)
)
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
expect(account.reload).to be_feature_enabled('captain_integration_v2')
end
end
context 'with Business plan' do
@@ -11,14 +11,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'disables the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration')
expect(account.reload.enabled_features.keys).not_to include(
'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
)
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding')
end
@@ -56,14 +58,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'does not disable the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration')
expect(account.reload.enabled_features.keys).to include(
'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
)
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding')
end
@@ -30,6 +30,14 @@ RSpec.describe Llm::BaseAiService do
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano')
end
it 'uses the Captain V2 assistant default ahead of the installation model' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
it 'uses the feature default when feature context has no account override or installation model' do
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::Models.default_model_for('assistant'))
end
+26
View File
@@ -30,6 +30,32 @@ RSpec.describe Llm::FeatureRouter do
)
end
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
account.enable_features!('captain_integration_v2')
resolved = described_class.resolve(feature: 'assistant', account: account)
expect(resolved).to include(
feature: 'assistant',
provider: 'openai',
model: 'gpt-5.2',
source: :default
)
expect(account.reload.captain_models).to be_nil
end
it 'keeps account model overrides ahead of the Captain V2 default' do
account.enable_features!('captain_integration_v2')
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
resolved = described_class.resolve(feature: 'assistant', account: account)
expect(resolved).to include(
model: 'gpt-5.1',
source: :account_override
)
end
it 'falls back to the feature default when the account override is invalid' do
account.captain_models = { 'editor' => 'invalid-model' }
+26
View File
@@ -50,6 +50,21 @@ RSpec.describe Account do
end
end
describe 'captain defaults for new accounts' do
it 'does not store Captain model overrides or enable premium Captain features' do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
value: Featurable::FEATURE_LIST,
locked: true
)
account = create(:account)
expect(account).not_to be_feature_enabled('captain_integration')
expect(account).not_to be_feature_enabled('captain_integration_v2')
expect(account.captain_models).to be_nil
end
end
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
@@ -337,6 +352,10 @@ RSpec.describe Account do
let(:account) { create(:account) }
describe 'with no saved preferences' do
before do
account.update!(captain_models: nil)
end
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
@@ -346,6 +365,13 @@ RSpec.describe Account do
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
end
end
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
account.enable_features!('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
end
describe 'with saved model preferences' do
@@ -58,15 +58,6 @@ RSpec.describe CaptainFeaturable do
end
describe 'model accessor methods' do
context 'when no models are explicitly configured' do
it 'returns default models for all features' do
Llm::Models.feature_keys.each do |feature_key|
expected_default = Llm::Models.default_model_for(feature_key)
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
end
end
end
context 'when models are explicitly configured' do
before do
account.update!(captain_models: {