diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 04eeff92b..482b001d6 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -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 diff --git a/enterprise/app/fields/captain_model_overrides_field.rb b/enterprise/app/fields/captain_model_overrides_field.rb index a8f3fe399..c361d511a 100644 --- a/enterprise/app/fields/captain_model_overrides_field.rb +++ b/enterprise/app/fields/captain_model_overrides_field.rb @@ -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 diff --git a/enterprise/app/models/concerns/agentable.rb b/enterprise/app/models/concerns/agentable.rb index b5a6cd1b9..086deedc1 100644 --- a/enterprise/app/models/concerns/agentable.rb +++ b/enterprise/app/models/concerns/agentable.rb @@ -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 diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index fcfe90777..62898803a 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -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 diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb index fea5b7664..205bc348e 100644 --- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -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 diff --git a/enterprise/app/services/llm/base_ai_service.rb b/enterprise/app/services/llm/base_ai_service.rb index bec3b5cb9..a84060775 100644 --- a/enterprise/app/services/llm/base_ai_service.rb +++ b/enterprise/app/services/llm/base_ai_service.rb @@ -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 diff --git a/enterprise/config/premium_features.yml b/enterprise/config/premium_features.yml index 282319fe7..260de1356 100644 --- a/enterprise/config/premium_features.yml +++ b/enterprise/config/premium_features.yml @@ -4,6 +4,7 @@ - sla - custom_roles - captain_integration +- captain_integration_v2 - captain_document_auto_sync - csat_review_notes - conversation_required_attributes diff --git a/lib/llm/feature_router.rb b/lib/llm/feature_router.rb index da0aa56e9..d75b06dca 100644 --- a/lib/llm/feature_router.rb +++ b/lib/llm/feature_router.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb index dfc2e4ff0..db7ca93a5 100644 --- a/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/captain/preferences_controller_spec.rb @@ -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 diff --git a/spec/controllers/super_admin/accounts_controller_spec.rb b/spec/controllers/super_admin/accounts_controller_spec.rb index b2f4ff405..366e178cd 100644 --- a/spec/controllers/super_admin/accounts_controller_spec.rb +++ b/spec/controllers/super_admin/accounts_controller_spec.rb @@ -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 diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index c69a83256..03f3b8d37 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -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) } diff --git a/spec/enterprise/models/concerns/agentable_spec.rb b/spec/enterprise/models/concerns/agentable_spec.rb index d179393c9..f2145ff85 100644 --- a/spec/enterprise/models/concerns/agentable_spec.rb +++ b/spec/enterprise/models/concerns/agentable_spec.rb @@ -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) diff --git a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb index 3223efa86..5116e1ec3 100644 --- a/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb +++ b/spec/enterprise/services/enterprise/billing/handle_stripe_event_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb index 63a318415..76e9e3602 100644 --- a/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb +++ b/spec/enterprise/services/internal/reconcile_plan_config_service_spec.rb @@ -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 diff --git a/spec/enterprise/services/llm/base_ai_service_spec.rb b/spec/enterprise/services/llm/base_ai_service_spec.rb index f18752485..d4c07d336 100644 --- a/spec/enterprise/services/llm/base_ai_service_spec.rb +++ b/spec/enterprise/services/llm/base_ai_service_spec.rb @@ -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 diff --git a/spec/lib/llm/feature_router_spec.rb b/spec/lib/llm/feature_router_spec.rb index e0eb4afa0..ea2b9f91f 100644 --- a/spec/lib/llm/feature_router_spec.rb +++ b/spec/lib/llm/feature_router_spec.rb @@ -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' } diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 56bd41f7c..e3bc636d1 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -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 diff --git a/spec/models/concerns/captain_featurable_spec.rb b/spec/models/concerns/captain_featurable_spec.rb index 7221ad055..0fb145142 100644 --- a/spec/models/concerns/captain_featurable_spec.rb +++ b/spec/models/concerns/captain_featurable_spec.rb @@ -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: {