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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
- sla
|
||||
- custom_roles
|
||||
- captain_integration
|
||||
- captain_integration_v2
|
||||
- captain_document_auto_sync
|
||||
- csat_review_notes
|
||||
- conversation_required_attributes
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user