From 716d18b27a9b5af4da72eb0fad6763a8084e4bf5 Mon Sep 17 00:00:00 2001
From: Gosia Ksionek <mksionek@gitlab.com>
Date: Tue, 9 Jan 2024 17:22:02 +0000
Subject: [PATCH] Enable Duo Chat for self-managed

---
 .../feature_flags/ops/ai_duo_chat_switch.yml  |   9 +
 ee/app/graphql/mutations/ai/action.rb         |  13 +-
 ee/app/policies/ee/global_policy.rb           |  21 +
 ee/app/policies/ee/group_policy.rb            |  18 +
 ee/app/policies/ee/project_policy.rb          |  20 +
 ee/app/services/llm/chat_service.rb           |   8 +
 .../llm/internal/completion_service.rb        |  10 +-
 ee/config/saas_features/duo_chat_on_saas.yml  |   5 +
 ee/lib/ee/gitlab/saas.rb                      |   1 +
 ee/lib/gitlab/llm/ai_message.rb               |   4 +
 ee/lib/gitlab/llm/chain/utils/authorizer.rb   |  24 +-
 ee/lib/gitlab/llm/chat_message.rb             |   4 +
 ee/lib/gitlab/llm/tanuki_bot.rb               |   8 +-
 ee/spec/features/tanuki_bot_chat_spec.rb      | 125 ++++-
 ee/spec/graphql/mutations/ai/action_spec.rb   |  53 +-
 ee/spec/lib/gitlab/llm/ai_message_spec.rb     |   6 +
 .../chain/tools/json_reader/executor_spec.rb  |  13 +-
 .../gitlab/llm/chain/utils/authorizer_spec.rb | 520 +++++++++++++-----
 ee/spec/lib/gitlab/llm/chat_message_spec.rb   |   6 +
 .../summarize_all_open_notes_spec.rb          |  20 +-
 ee/spec/lib/gitlab/llm/tanuki_bot_spec.rb     | 220 +++++---
 ee/spec/policies/global_policy_spec.rb        |  51 ++
 ee/spec/policies/group_policy_spec.rb         |  80 +++
 ee/spec/policies/project_policy_spec.rb       | 101 ++++
 .../graphql/mutations/projects/chat_spec.rb   |   4 +-
 .../mutations/projects/explain_code_spec.rb   |   2 +-
 .../fill_in_merge_request_template_spec.rb    |   2 +-
 .../projects/generate_commit_message_spec.rb  |   2 +-
 .../projects/generate_test_file_spec.rb       |   2 +-
 ee/spec/services/llm/base_service_spec.rb     |  78 +--
 ee/spec/services/llm/chat_service_spec.rb     |  79 ++-
 ..._in_merge_request_template_service_spec.rb |   6 +-
 .../llm/generate_description_service_spec.rb  |   6 +-
 .../llm/generate_summary_service_spec.rb      |   6 +-
 .../llm/internal/completion_service_spec.rb   |  14 +
 .../summarize_merge_request_service_spec.rb   |   6 +-
 ...summarize_submitted_review_service_spec.rb |   6 +-
 ...atures_enabled_for_group_shared_context.rb |  20 +-
 locale/gitlab.pot                             |   4 +-
 39 files changed, 1221 insertions(+), 356 deletions(-)
 create mode 100644 config/feature_flags/ops/ai_duo_chat_switch.yml
 create mode 100644 ee/config/saas_features/duo_chat_on_saas.yml

diff --git a/config/feature_flags/ops/ai_duo_chat_switch.yml b/config/feature_flags/ops/ai_duo_chat_switch.yml
new file mode 100644
index 0000000000000..cd3fe3ab937f1
--- /dev/null
+++ b/config/feature_flags/ops/ai_duo_chat_switch.yml
@@ -0,0 +1,9 @@
+---
+name: ai_duo_chat_switch
+feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/434802
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140352
+rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/production/-/issues/17301
+milestone: '16.8'
+group: group::ai framework
+type: ops
+default_enabled: true
diff --git a/ee/app/graphql/mutations/ai/action.rb b/ee/app/graphql/mutations/ai/action.rb
index abb54d765fe88..1605200725ac3 100644
--- a/ee/app/graphql/mutations/ai/action.rb
+++ b/ee/app/graphql/mutations/ai/action.rb
@@ -29,10 +29,12 @@ def ready?(**args)
       end
 
       def resolve(**attributes)
-        check_feature_flag_enabled!
         verify_rate_limit!
 
         resource_id, method, options = extract_method_params!(attributes)
+
+        check_feature_flag_enabled!(method)
+
         resource = resource_id&.then { |id| authorized_find!(id: id) }
 
         options[:referer_url] = context[:request].headers["Referer"] if method == :chat
@@ -49,10 +51,13 @@ def resolve(**attributes)
 
       private
 
-      def check_feature_flag_enabled!
-        return if Feature.enabled?(:ai_global_switch, type: :ops)
+      def check_feature_flag_enabled!(method)
+        is_chat = method.eql?(:chat)
+
+        return if Feature.enabled?(:ai_duo_chat_switch, type: :ops) && is_chat
+        return if Feature.enabled?(:ai_global_switch, type: :ops) && !is_chat
 
-        raise Gitlab::Graphql::Errors::ResourceNotAvailable, '`ai_global_switch` feature flag is disabled.'
+        raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'required feature flag is disabled.'
       end
 
       def verify_rate_limit!
diff --git a/ee/app/policies/ee/global_policy.rb b/ee/app/policies/ee/global_policy.rb
index 5fd1e63305b6f..098298e989aa6 100644
--- a/ee/app/policies/ee/global_policy.rb
+++ b/ee/app/policies/ee/global_policy.rb
@@ -79,6 +79,25 @@ module GlobalPolicy
         @user.code_suggestions_disabled_by_group?
       end
 
+      condition(:duo_chat_enabled_by_instance) do
+        next true if ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
+
+        ::Gitlab::CurrentSettings.instance_level_ai_beta_features_enabled?
+      end
+
+      condition(:duo_chat_licensed) do
+        next true if ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
+
+        ::License.feature_available?(:ai_chat)
+      end
+
+      condition(:user_allowed_to_use_chat) do
+        next false unless @user
+        next true unless ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
+
+        user.any_group_with_ai_available?
+      end
+
       condition(:user_belongs_to_paid_namespace) do
         next false unless @user
 
@@ -155,6 +174,8 @@ module GlobalPolicy
         .enable :access_code_suggestions
       rule { code_suggestions_disabled_by_group }.prevent :access_code_suggestions
 
+      rule { duo_chat_licensed & duo_chat_enabled_by_instance & user_allowed_to_use_chat }.enable :access_duo_chat
+
       rule { runner_upgrade_management_available | user_belongs_to_paid_namespace }.enable :read_runner_upgrade_status
 
       rule { security_policy_bot }.policy do
diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb
index 26155b4c65604..cec0a34eb05ea 100644
--- a/ee/app/policies/ee/group_policy.rb
+++ b/ee/app/policies/ee/group_policy.rb
@@ -257,6 +257,22 @@ module GroupPolicy
           can?(:developer_access)
       end
 
+      condition(:chat_allowed_for_group, scope: :subject) do
+        next true unless ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
+
+        ::Gitlab::Llm::StageCheck.available?(@subject, :chat)
+      end
+
+      condition(:chat_available_for_user, scope: :global) do
+        Ability.allowed?(@user, :access_duo_chat)
+      end
+
+      condition(:membership_for_chat) do
+        next Ability.allowed?(@user, :read_group, @subject) unless ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
+
+        @subject.member?(@user)
+      end
+
       rule { user_banned_from_namespace }.prevent_all
 
       rule { public_group | logged_in_viewable }.policy do
@@ -639,6 +655,8 @@ module GroupPolicy
       end
 
       rule { guest }.enable :read_limit_alert
+
+      rule { membership_for_chat & chat_allowed_for_group & chat_available_for_user }.enable :access_duo_chat
     end
 
     override :lookup_access_level!
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index 86689a19adb96..b9772a9784af5 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -344,6 +344,24 @@ module ProjectPolicy
         project.ci_cancellation_restriction.no_one_allowed?
       end
 
+      condition(:chat_allowed_for_parent_group, scope: :subject) do
+        next true unless ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
+
+        ::Gitlab::Llm::StageCheck.available?(@subject.parent, :chat)
+      end
+
+      condition(:chat_available_for_user, scope: :global) do
+        Ability.allowed?(@user, :access_duo_chat)
+      end
+
+      condition(:membership_for_chat, scope: :global) do
+        unless ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
+          next Ability.allowed?(@user, :read_project, @subject)
+        end
+
+        team_member?
+      end
+
       rule { visual_review_bot }.policy do
         prevent :read_note
         enable :create_note
@@ -852,6 +870,8 @@ module ProjectPolicy
       rule { can?(:reporter_access) & agent_registry_enabled }.policy do
         enable :write_ai_agents
       end
+
+      rule { membership_for_chat & chat_allowed_for_parent_group & chat_available_for_user }.enable :access_duo_chat
     end
 
     override :lookup_access_level!
diff --git a/ee/app/services/llm/chat_service.rb b/ee/app/services/llm/chat_service.rb
index fc487d1d136cd..505642171ea73 100644
--- a/ee/app/services/llm/chat_service.rb
+++ b/ee/app/services/llm/chat_service.rb
@@ -17,5 +17,13 @@ def perform
     def content(_action_name)
       options[:content]
     end
+
+    def ai_integration_enabled?
+      ::Feature.enabled?(:ai_duo_chat_switch, type: :ops)
+    end
+
+    def user_can_send_to_ai?
+      user.can?(:access_duo_chat)
+    end
   end
 end
diff --git a/ee/app/services/llm/internal/completion_service.rb b/ee/app/services/llm/internal/completion_service.rb
index 60209067b4b3b..2c061bdbd96fe 100644
--- a/ee/app/services/llm/internal/completion_service.rb
+++ b/ee/app/services/llm/internal/completion_service.rb
@@ -15,7 +15,7 @@ def initialize(prompt_message, options = {})
       end
 
       def execute
-        return unless Feature.enabled?(:ai_global_switch, type: :ops)
+        return unless ai_action_enabled?(prompt_message)
 
         with_tracking(prompt_message.ai_action) do
           break unless resource_authorized?(prompt_message)
@@ -94,6 +94,14 @@ def update_duration_metric(ai_action_name, duration)
         )
       end
 
+      def ai_action_enabled?(prompt_message)
+        if prompt_message.chat?
+          Feature.enabled?(:ai_duo_chat_switch, type: :ops)
+        else
+          Feature.enabled?(:ai_global_switch, type: :ops)
+        end
+      end
+
       def logger
         @logger ||= Gitlab::Llm::Logger.build
       end
diff --git a/ee/config/saas_features/duo_chat_on_saas.yml b/ee/config/saas_features/duo_chat_on_saas.yml
new file mode 100644
index 0000000000000..5de1785485739
--- /dev/null
+++ b/ee/config/saas_features/duo_chat_on_saas.yml
@@ -0,0 +1,5 @@
+---
+name: duo_chat_on_saas
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140352
+milestone: '16.8'
+group: group::ai framework
diff --git a/ee/lib/ee/gitlab/saas.rb b/ee/lib/ee/gitlab/saas.rb
index 8b94724e9594d..5e1bf4ec4158e 100644
--- a/ee/lib/ee/gitlab/saas.rb
+++ b/ee/lib/ee/gitlab/saas.rb
@@ -21,6 +21,7 @@ module Saas
           gitlab_saas_subscriptions
           duo_chat_categorize_question
           google_artifact_registry
+          duo_chat_on_saas
         ].freeze
 
       CONFIG_FILE_ROOT = 'ee/config/saas_features'
diff --git a/ee/lib/gitlab/llm/ai_message.rb b/ee/lib/gitlab/llm/ai_message.rb
index 0bf1d8e4471e5..6d16e5da625bd 100644
--- a/ee/lib/gitlab/llm/ai_message.rb
+++ b/ee/lib/gitlab/llm/ai_message.rb
@@ -77,6 +77,10 @@ def slash_command_and_input
         content.split(' ', 2)
       end
 
+      def chat?
+        false
+      end
+
       def ==(other)
         super || (
           self.class == other.class &&
diff --git a/ee/lib/gitlab/llm/chain/utils/authorizer.rb b/ee/lib/gitlab/llm/chain/utils/authorizer.rb
index a992a1bdba25e..f991001d680d2 100644
--- a/ee/lib/gitlab/llm/chain/utils/authorizer.rb
+++ b/ee/lib/gitlab/llm/chain/utils/authorizer.rb
@@ -12,6 +12,8 @@ def allowed?
           end
 
           def self.context(context:)
+            return Response.new(allowed: false, message: no_access_message) unless context.current_user
+
             if context.resource && context.container
               authorization_container = container(container: context.container, user: context.current_user)
               if authorization_container.allowed?
@@ -33,10 +35,14 @@ def self.context_allowed?(context:)
           end
 
           def self.container(container:, user:)
-            return Response.new(allowed: false, message: not_found_message) unless container.member?(user)
+            return user(user: user) unless ::Gitlab::Saas.feature_available?(:duo_chat_on_saas)
 
-            allowed = Gitlab::Llm::StageCheck.available?(container, :chat)
-            message = s_("This feature is only allowed in groups that enable this feature.") unless allowed
+            allowed = user.can?(:access_duo_chat, container)
+            message = if !allowed && container.member?(user)
+                        no_ai_message
+                      elsif !allowed
+                        not_found_message
+                      end
 
             Response.new(allowed: allowed, message: message)
           end
@@ -61,8 +67,8 @@ def self.resource(resource:, user:)
           end
 
           def self.user(user:)
-            allowed = user.any_group_with_ai_available?
-            message = s_("You do not have access to AI features.") unless allowed
+            allowed = user.can?(:access_duo_chat)
+            message = no_access_message unless allowed
             Response.new(allowed: allowed, message: message)
           end
 
@@ -75,6 +81,14 @@ def self.user_as_resource(resource:, user:)
           def self.not_found_message
             s_("I am sorry, I am unable to find what you are looking for.")
           end
+
+          def self.no_access_message
+            s_("You do not have access to chat feature.")
+          end
+
+          def self.no_ai_message
+            s_("This feature is only allowed in groups that enable this feature.")
+          end
         end
       end
     end
diff --git a/ee/lib/gitlab/llm/chat_message.rb b/ee/lib/gitlab/llm/chat_message.rb
index b4c138dc8ee4c..cde1808fbda9c 100644
--- a/ee/lib/gitlab/llm/chat_message.rb
+++ b/ee/lib/gitlab/llm/chat_message.rb
@@ -28,6 +28,10 @@ def clean_history?
       def question?
         user? && !conversation_reset? && !clean_history?
       end
+
+      def chat?
+        true
+      end
     end
   end
 end
diff --git a/ee/lib/gitlab/llm/tanuki_bot.rb b/ee/lib/gitlab/llm/tanuki_bot.rb
index e62a8bf5f7798..8778a29e13334 100644
--- a/ee/lib/gitlab/llm/tanuki_bot.rb
+++ b/ee/lib/gitlab/llm/tanuki_bot.rb
@@ -12,15 +12,11 @@ class TanukiBot
       MODEL = 'claude-instant-1.1'
 
       def self.enabled_for?(user:, container: nil)
-        return false unless Feature.enabled?(:ai_global_switch, type: :ops)
+        return false unless Feature.enabled?(:ai_duo_chat_switch, type: :ops)
 
         return false unless user
 
-        if container
-          container.member?(user) && Gitlab::Llm::StageCheck.available?(container.resource_parent, :chat)
-        else
-          user.any_group_with_ai_available?
-        end
+        container ? user.can?(:access_duo_chat, container) : user.can?(:access_duo_chat)
       end
 
       def self.show_breadcrumbs_entry_point?(user:, container: nil)
diff --git a/ee/spec/features/tanuki_bot_chat_spec.rb b/ee/spec/features/tanuki_bot_chat_spec.rb
index f1df78c7fabf6..f3946a5c4e53a 100644
--- a/ee/spec/features/tanuki_bot_chat_spec.rb
+++ b/ee/spec/features/tanuki_bot_chat_spec.rb
@@ -5,59 +5,126 @@
 RSpec.describe 'GitLab Duo Chat', :js, feature_category: :global_search do
   let_it_be(:user) { create(:user) }
 
-  before do
-    allow(License).to receive(:feature_available?).and_return(true)
-    allow(user).to receive(:any_group_with_ai_available?).and_return(true)
+  context 'for saas', :saas do
+    let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
 
-    sign_in(user)
-  end
+    before_all do
+      group.add_developer(user)
+    end
 
-  describe 'Feature enabled and available' do
     before do
-      visit root_path
+      sign_in(user)
     end
 
-    shared_examples 'GitLab Duo drawer' do
-      it 'opens the drawer to chat with GitLab Duo' do
-        wait_for_requests
+    describe 'Feature enabled and available' do
+      include_context 'with ai features enabled for group'
+
+      before do
+        visit root_path
+      end
+
+      shared_examples 'GitLab Duo drawer' do
+        it 'opens the drawer to chat with GitLab Duo' do
+          wait_for_requests
 
-        within_testid('chat-component') do
-          expect(page).to have_text('GitLab Duo Chat')
+          within_testid('chat-component') do
+            expect(page).to have_text('GitLab Duo Chat')
+          end
         end
       end
-    end
 
-    context "when opening the drawer from the help center" do
-      before do
-        within_testid('super-sidebar') do
-          click_button('Help')
-          click_button('GitLab Duo Chat')
+      context "when opening the drawer from the help center" do
+        before do
+          within_testid('super-sidebar') do
+            click_button('Help')
+            click_button('GitLab Duo Chat')
+          end
         end
+
+        it_behaves_like 'GitLab Duo drawer'
       end
 
-      it_behaves_like 'GitLab Duo drawer'
+      context "when opening the drawer from the breadcrumbs" do
+        before do
+          within_testid('top-bar') do
+            click_button('GitLab Duo Chat')
+          end
+        end
+
+        it_behaves_like 'GitLab Duo drawer'
+      end
     end
 
-    context "when opening the drawer from the breadcrumbs" do
+    context 'when the :tanuki_bot_breadcrumbs_entry_point feature flag is off' do
+      include_context 'with ai features enabled for group'
+
       before do
+        stub_feature_flags(tanuki_bot_breadcrumbs_entry_point: false)
+        visit root_path
+      end
+
+      it 'does not show the entry point in the breadcrumbs' do
         within_testid('top-bar') do
-          click_button('GitLab Duo Chat')
+          expect(page).not_to have_button('GitLab Duo Chat')
         end
       end
-
-      it_behaves_like 'GitLab Duo drawer'
     end
   end
 
-  context 'when the :tanuki_bot_breadcrumbs_entry_point feature flag is off' do
+  context 'for self-managed' do
     before do
-      stub_feature_flags(tanuki_bot_breadcrumbs_entry_point: false)
-      visit root_path
+      sign_in(user)
+    end
+
+    describe 'Feature enabled and available' do
+      include_context 'with experiment features enabled for self-managed'
+
+      before do
+        visit root_path
+      end
+
+      shared_examples 'GitLab Duo drawer' do
+        it 'opens the drawer to chat with GitLab Duo' do
+          wait_for_requests
+
+          within_testid('chat-component') do
+            expect(page).to have_text('GitLab Duo Chat')
+          end
+        end
+      end
+
+      context "when opening the drawer from the help center" do
+        before do
+          within_testid('super-sidebar') do
+            click_button('Help')
+            click_button('GitLab Duo Chat')
+          end
+        end
+
+        it_behaves_like 'GitLab Duo drawer'
+      end
+
+      context "when opening the drawer from the breadcrumbs" do
+        before do
+          within_testid('top-bar') do
+            click_button('GitLab Duo Chat')
+          end
+        end
+
+        it_behaves_like 'GitLab Duo drawer'
+      end
     end
 
-    it 'does not show the entry point in the breadcrumbs' do
-      within_testid('top-bar') do
-        expect(page).not_to have_button('GitLab Duo Chat')
+    context 'when the :tanuki_bot_breadcrumbs_entry_point feature flag is off' do
+      before do
+        stub_feature_flags(tanuki_bot_breadcrumbs_entry_point: false)
+        visit root_path
+      end
+
+      it 'does not show the entry point in the breadcrumbs' do
+        within_testid('top-bar') do
+          expect(page).not_to have_button('GitLab Duo Chat')
+        end
       end
     end
   end
diff --git a/ee/spec/graphql/mutations/ai/action_spec.rb b/ee/spec/graphql/mutations/ai/action_spec.rb
index fe782686e3677..89d4ec4fd1465 100644
--- a/ee/spec/graphql/mutations/ai/action_spec.rb
+++ b/ee/spec/graphql/mutations/ai/action_spec.rb
@@ -39,6 +39,24 @@
       mutation.resolve(**input)
     end
 
+    shared_examples_for 'an AI action when feature flag disabled' do
+      context 'when the user can perform AI action' do
+        before do
+          resource.project.add_developer(user)
+        end
+
+        context 'when feature flag is disabled' do
+          before do
+            stub_feature_flags(ai_global_switch: false)
+          end
+
+          it 'raises error' do
+            expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+          end
+        end
+      end
+    end
+
     shared_examples_for 'an AI action' do
       context 'when resource_id is not for an Ai::Model' do
         let(:resource_id) { "gid://gitlab/Note/#{resource.id}" }
@@ -83,6 +101,10 @@
               .to receive(:allowed?)
               .with(user, "read_#{resource.to_ability_name}", resource)
               .and_return(true)
+
+          allow(Ability)
+            .to receive(:allowed?)
+            .and_call_original
         end
 
         context 'when the user is not a member' do
@@ -105,16 +127,6 @@
           resource.project.add_developer(user)
         end
 
-        context 'when feature flag is disabled' do
-          before do
-            stub_feature_flags(ai_global_switch: false)
-          end
-
-          it 'raises error' do
-            expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
-          end
-        end
-
         it 'calls Llm::ExecuteMethodService' do
           expect_next_instance_of(
             Llm::ExecuteMethodService,
@@ -190,6 +202,22 @@
         let(:expected_method) { :chat }
         let(:expected_options) { { referer_url: "foobar", user_agent: "user-agent" } }
       end
+
+      context 'when the user can perform AI action' do
+        before do
+          resource.project.add_developer(user)
+        end
+
+        context 'when feature flag is disabled' do
+          before do
+            stub_feature_flags(ai_duo_chat_switch: false)
+          end
+
+          it 'raises error' do
+            expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+          end
+        end
+      end
     end
 
     context 'when summarize_comments input is set' do
@@ -198,6 +226,7 @@
       let(:expected_options) { { user_agent: "user-agent" } }
 
       it_behaves_like 'an AI action'
+      it_behaves_like 'an AI action when feature flag disabled'
     end
 
     context 'when client_subscription_id input is set' do
@@ -206,9 +235,10 @@
       let(:expected_options) { { client_subscription_id: 'id', user_agent: 'user-agent' } }
 
       it_behaves_like 'an AI action'
+      it_behaves_like 'an AI action when feature flag disabled'
     end
 
-    context 'when explain_vulnerability input is set' do
+    context 'when explain_vulnerability input is set', :saas do
       before do
         allow(Ability)
             .to receive(:allowed?)
@@ -225,6 +255,7 @@
       let(:expected_options) { { include_source_code: true, user_agent: 'user-agent' } }
 
       it_behaves_like 'an AI action'
+      it_behaves_like 'an AI action when feature flag disabled'
     end
   end
 end
diff --git a/ee/spec/lib/gitlab/llm/ai_message_spec.rb b/ee/spec/lib/gitlab/llm/ai_message_spec.rb
index 3ac7f6b13d48b..efa83d8cd4c23 100644
--- a/ee/spec/lib/gitlab/llm/ai_message_spec.rb
+++ b/ee/spec/lib/gitlab/llm/ai_message_spec.rb
@@ -187,4 +187,10 @@
       expect(m1).not_to eq(m2)
     end
   end
+
+  describe "#chat?" do
+    it 'returns true for chat message' do
+      expect(subject).not_to be_chat
+    end
+  end
 end
diff --git a/ee/spec/lib/gitlab/llm/chain/tools/json_reader/executor_spec.rb b/ee/spec/lib/gitlab/llm/chain/tools/json_reader/executor_spec.rb
index 38d6b06e62b60..ec43d8882d83e 100644
--- a/ee/spec/lib/gitlab/llm/chain/tools/json_reader/executor_spec.rb
+++ b/ee/spec/lib/gitlab/llm/chain/tools/json_reader/executor_spec.rb
@@ -6,8 +6,8 @@
   subject(:reader) { described_class.new(context: context, options: options, stream_response_handler: nil) }
 
   let_it_be(:user) { create(:user) }
-  let_it_be(:group) { create(:group_with_plan, plan: :ultimate_plan) }
-  let_it_be(:project) { create(:project, group: group, name: "My sweet project with robots in it") }
+  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+  let_it_be_with_reload(:project) { create(:project, group: group, name: "My sweet project with robots in it") }
   let_it_be(:issue) do
     create(:issue, project: project, title: "AI should be included at birthdays", description: description_content)
   end
@@ -34,7 +34,6 @@
     allow(reader).to receive(:provider_prompt_class)
       .and_return(::Gitlab::Llm::Chain::Tools::JsonReader::Prompts::Anthropic)
     stub_const("::Gitlab::Llm::Chain::Tools::JsonReader::Prompts::Anthropic::MAX_CHARACTERS", 4)
-    allow(Gitlab::Llm::StageCheck).to receive(:available?).with(project, :chat).and_return(true)
   end
 
   describe '#name' do
@@ -58,8 +57,14 @@
 
   describe '#execute' do
     before do
+      allow(Gitlab).to receive(:org_or_com?).and_return(true)
       stub_ee_application_setting(should_check_namespace_plan: true)
-      stub_licensed_features(experimental_features: true, ai_features: true)
+      allow(group.namespace_settings).to receive(:experiment_settings_allowed?).and_return(true)
+      stub_licensed_features(
+        ai_features: true,
+        epics: true,
+        experimental_features: true
+      )
       group.namespace_settings.update!(experiment_features_enabled: true)
     end
 
diff --git a/ee/spec/lib/gitlab/llm/chain/utils/authorizer_spec.rb b/ee/spec/lib/gitlab/llm/chain/utils/authorizer_spec.rb
index a10581288c241..2600fb8a73870 100644
--- a/ee/spec/lib/gitlab/llm/chain/utils/authorizer_spec.rb
+++ b/ee/spec/lib/gitlab/llm/chain/utils/authorizer_spec.rb
@@ -2,241 +2,471 @@
 
 require 'spec_helper'
 
-RSpec.describe Gitlab::Llm::Chain::Utils::Authorizer, :saas, feature_category: :duo_chat do
-  let_it_be(:group) { create(:group_with_plan, :public, plan: :ultimate_plan) }
-  let_it_be_with_reload(:project) {  create(:project, group: group) }
-  let_it_be_with_reload(:resource) { create(:issue, project: project) }
-  let_it_be(:user) { create(:user) }
-  let(:container) { project }
-  let(:context) do
-    Gitlab::Llm::Chain::GitlabContext.new(
-      current_user: user,
-      container: container,
-      resource: resource,
-      ai_request: nil
-    )
-  end
+RSpec.describe Gitlab::Llm::Chain::Utils::Authorizer, feature_category: :duo_chat do
+  context 'for saas', :saas do
+    let_it_be(:group) { create(:group_with_plan, :public, plan: :ultimate_plan) }
+    let_it_be_with_reload(:project) {  create(:project, group: group) }
+    let_it_be_with_reload(:resource) { create(:issue, project: project) }
+    let_it_be(:user) { create(:user) }
+    let(:container) { project }
+    let(:context) do
+      Gitlab::Llm::Chain::GitlabContext.new(
+        current_user: user,
+        container: container,
+        resource: resource,
+        ai_request: nil
+      )
+    end
 
-  subject(:authorizer) { described_class }
+    subject(:authorizer) { described_class }
 
-  before_all do
-    group.add_developer(user)
-  end
+    before_all do
+      group.add_developer(user)
+    end
 
-  shared_examples 'user authorization' do
-    context 'when user has groups with ai available' do
-      include_context 'with ai features enabled for group'
-      it 'returns true' do
-        expect(authorizer.user(user: user).allowed?).to be(true)
-      end
+    before do
+      allow(Gitlab).to receive(:org_or_com?).and_return(true)
     end
 
-    context 'when user has no groups with ai available' do
-      include_context 'with experiment features disabled for group'
+    shared_examples 'user authorization' do
+      context 'when user has groups with ai available' do
+        include_context 'with ai features enabled for group'
+        it 'returns true' do
+          expect(authorizer.user(user: user).allowed?).to be(true)
+        end
+      end
+
+      context 'when user has no groups with ai available' do
+        include_context 'with experiment features disabled for group'
 
-      it 'returns true when user has no groups with ai available' do
-        expect(authorizer.user(user: user).allowed?).to be(false)
-        expect(authorizer.user(user: user).message).to eq('You do not have access to AI features.')
+        it 'returns true when user has no groups with ai available' do
+          expect(authorizer.user(user: user).allowed?).to be(false)
+          expect(authorizer.user(user: user).message).to eq('You do not have access to chat feature.')
+        end
       end
     end
-  end
 
-  describe '.context.allowed?' do
-    context 'when both resource and container are present' do
-      context 'when container is authorized' do
-        include_context 'with ai features enabled for group'
+    describe '.context.allowed?' do
+      context 'when both resource and container are present' do
+        context 'when container is authorized' do
+          include_context 'with ai features enabled for group'
+
+          it 'returns true if both resource and container are authorized' do
+            expect(authorizer.context(context: context).allowed?).to be(true)
+          end
+
+          it 'returns false if resource is not authorized' do
+            group.members.first.destroy!
 
-        it 'returns true if both resource and container are authorized' do
-          expect(authorizer.context(context: context).allowed?).to be(true)
+            expect(authorizer.context(context: context).allowed?).to be(false)
+            expect(authorizer.context(context: context).message)
+              .to include('I am unable to find what you are looking for.')
+          end
         end
 
-        it 'returns false if resource is not authorized' do
-          group.members.first.destroy!
+        context 'when container is not authorized' do
+          include_context 'with experiment features disabled for group'
 
-          expect(authorizer.context(context: context).allowed?).to be(false)
-          expect(authorizer.context(context: context).message)
-            .to include('I am unable to find what you are looking for.')
+          it 'returns false if container is not authorized' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+            expect(authorizer.context(context: context).message)
+              .to eq('This feature is only allowed in groups that enable this feature.')
+          end
         end
       end
 
-      context 'when container is not authorized' do
-        include_context 'with experiment features disabled for group'
+      context 'when only resource is present' do
+        let(:context) do
+          Gitlab::Llm::Chain::GitlabContext.new(
+            current_user: user,
+            container: nil,
+            resource: resource,
+            ai_request: nil
+          )
+        end
+
+        context 'when resource container is authorized' do
+          include_context 'with ai features enabled for group'
 
-        it 'returns false if container is not authorized' do
-          expect(authorizer.context(context: context).allowed?).to be(false)
-          expect(authorizer.context(context: context).message)
-            .to eq('This feature is only allowed in groups that enable this feature.')
+          it 'returns true' do
+            expect(authorizer.context(context: context).allowed?).to be(true)
+          end
         end
-      end
-    end
 
-    context 'when only resource is present' do
-      let(:context) do
-        Gitlab::Llm::Chain::GitlabContext.new(
-          current_user: user,
-          container: nil,
-          resource: resource,
-          ai_request: nil
-        )
+        context 'when container is not authorized' do
+          include_context 'with experiment features disabled for group'
+
+          it 'returns false' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
+        end
       end
 
-      context 'when resource container is authorized' do
-        include_context 'with ai features enabled for group'
+      context 'when only container is present' do
+        let(:context) do
+          Gitlab::Llm::Chain::GitlabContext.new(
+            current_user: nil,
+            container: container,
+            resource: nil,
+            ai_request: nil
+          )
+        end
 
-        it 'returns true' do
-          expect(authorizer.context(context: context).allowed?).to be(true)
+        context 'when container is authorized' do
+          include_context 'with ai features enabled for group'
+
+          it 'returns true' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
+        end
+
+        context 'when container is not authorized' do
+          include_context 'with experiment features disabled for group'
+
+          it 'returns false' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
         end
       end
 
-      context 'when container is not authorized' do
-        include_context 'with experiment features disabled for group'
+      context 'when neither resource nor container is present' do
+        let(:context) do
+          Gitlab::Llm::Chain::GitlabContext.new(
+            current_user: user,
+            container: nil,
+            resource: nil,
+            ai_request: nil
+          )
+        end
 
-        it 'returns false' do
-          expect(authorizer.context(context: context).allowed?).to be(false)
+        context 'when user is authorized' do
+          include_context 'with ai features enabled for group'
+
+          it 'returns true' do
+            expect(authorizer.context(context: context).allowed?).to be(true)
+          end
+        end
+
+        context 'when user is not authorized' do
+          include_context 'with experiment features disabled for group'
+
+          it 'returns false' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
         end
       end
     end
 
-    context 'when only container is present' do
-      let(:context) do
-        Gitlab::Llm::Chain::GitlabContext.new(
-          current_user: nil,
-          container: container,
-          resource: nil,
-          ai_request: nil
-        )
+    describe '.container' do
+      it "calls policy with the appropriate arguments" do
+        expect(user).to receive(:can?).with(:access_duo_chat, container)
+
+        authorizer.container(container: context.container, user: user)
       end
 
-      context 'when container is authorized' do
-        include_context 'with ai features enabled for group'
+      it 'uses resource from argument' do
+        new_container = create(:group)
+        allow(user).to receive(:can?).with(:admin_all_resources).and_call_original
 
-        it 'returns true' do
-          expect(authorizer.context(context: context).allowed?).to be(false)
-        end
+        expect(user).to receive(:can?).at_least(:once).with(:access_duo_chat, new_container)
+
+        authorizer.container(container: new_container, user: user)
       end
+    end
 
-      context 'when container is not authorized' do
-        include_context 'with experiment features disabled for group'
+    describe '.resource' do
+      context 'when resource is nil' do
+        let(:resource) { nil }
 
         it 'returns false' do
-          expect(authorizer.context(context: context).allowed?).to be(false)
+          expect(authorizer.resource(resource: context.resource, user: context.current_user).allowed?)
+            .to be(false)
         end
       end
-    end
 
-    context 'when neither resource nor container is present' do
-      let(:context) do
-        Gitlab::Llm::Chain::GitlabContext.new(
-          current_user: user,
-          container: nil,
-          resource: nil,
-          ai_request: nil
-        )
+      context 'when resource parent is not authorized' do
+        include_context 'with experiment features disabled for group'
+
+        it 'returns false' do
+          expect(authorizer.resource(resource: context.resource, user: context.current_user).allowed?)
+            .to be(false)
+        end
       end
 
-      context 'when user is authorized' do
+      context 'when resource container is authorized' do
         include_context 'with ai features enabled for group'
 
-        it 'returns true' do
-          expect(authorizer.context(context: context).allowed?).to be(true)
+        it 'calls user.can? with the appropriate arguments' do
+          expect(user).to receive(:can?).with('read_issue', resource)
+
+          authorizer.resource(resource: context.resource, user: context.current_user)
+        end
+
+        it 'uses resource from argument' do
+          new_resource = build(:epic)
+
+          expect(new_resource).to receive(:resource_parent).and_return(group)
+          expect(user).to receive(:can?).with('read_epic', new_resource)
+
+          authorizer.resource(resource: new_resource, user: context.current_user)
         end
       end
 
-      context 'when user is not authorized' do
-        include_context 'with experiment features disabled for group'
+      context 'when resource is current user' do
+        context 'when user is not in any group with ai' do
+          include_context 'with experiment features disabled for group'
 
-        it 'returns false' do
-          expect(authorizer.context(context: context).allowed?).to be(false)
+          it 'returns false' do
+            expect(authorizer.resource(resource: context.current_user, user: context.current_user).allowed?)
+              .to be(false)
+          end
+        end
+
+        context 'when user is in any group with ai' do
+          include_context 'with ai features enabled for group'
+
+          it 'returns true' do
+            expect(authorizer.resource(resource: context.current_user, user: context.current_user).allowed?)
+              .to be(true)
+          end
+
+          context 'when resource is different user' do
+            let(:resource) { build(:user) }
+
+            it 'returns false' do
+              expect(authorizer.resource(resource: resource, user: context.current_user).allowed?)
+                .to be(false)
+            end
+          end
         end
       end
     end
-  end
 
-  describe '.container?' do
-    it "calls Gitlab::Llm::StageCheck.available? with the appropriate arguments" do
-      expect(Gitlab::Llm::StageCheck).to receive(:available?).with(container, :chat)
+    describe '.user' do
+      it_behaves_like 'user authorization'
+    end
+  end
 
-      authorizer.container(container: context.container, user: user)
+  context 'for self-managed' do
+    let_it_be(:group) { create(:group) }
+    let_it_be_with_reload(:project) {  create(:project, group: group) }
+    let_it_be_with_reload(:resource) { create(:issue, project: project) }
+    let_it_be(:user) { create(:user) }
+    let(:container) { project }
+    let(:context) do
+      Gitlab::Llm::Chain::GitlabContext.new(
+        current_user: user,
+        container: container,
+        resource: resource,
+        ai_request: nil
+      )
     end
 
-    it 'uses resource from argument' do
-      new_container = build(:group)
-      expect(new_container).to receive(:member?).and_return(true)
-      expect(Gitlab::Llm::StageCheck).to receive(:available?).with(new_container, :chat)
+    subject(:authorizer) { described_class }
 
-      authorizer.container(container: new_container, user: user)
+    before_all do
+      group.add_developer(user)
     end
-  end
 
-  describe '.resource' do
-    context 'when resource is nil' do
-      let(:resource) { nil }
+    shared_examples 'user authorization' do
+      context 'when ai is enabled for self-managed' do
+        include_context 'with experiment features enabled for self-managed'
+        it 'returns true' do
+          expect(authorizer.user(user: user).allowed?).to be(true)
+        end
+      end
+
+      context 'when ai is disabled for self-managed' do
+        include_context 'with experiment features disabled for self-managed'
 
-      it 'returns false' do
-        expect(authorizer.resource(resource: context.resource, user: context.current_user).allowed?)
-          .to be(false)
+        it 'returns true when user has no groups with ai available' do
+          expect(authorizer.user(user: user).allowed?).to be(false)
+          expect(authorizer.user(user: user).message).to eq('You do not have access to chat feature.')
+        end
       end
     end
 
-    context 'when resource parent is not authorized' do
-      include_context 'with experiment features disabled for group'
+    describe '.context.allowed?' do
+      context 'when both resource and container are present' do
+        context 'when ai is enabled for self-managed' do
+          include_context 'with experiment features enabled for self-managed'
+
+          it 'returns true if both resource and container are authorized' do
+            expect(authorizer.context(context: context).allowed?).to be(true)
+          end
+
+          it 'returns false if resource is not authorized' do
+            group.members.first.destroy!
+
+            expect(authorizer.context(context: context).allowed?).to be(false)
+            expect(authorizer.context(context: context).message)
+              .to include('I am unable to find what you are looking for.')
+          end
+        end
 
-      it 'returns false' do
-        expect(authorizer.resource(resource: context.resource, user: context.current_user).allowed?)
-          .to be(false)
+        context 'when ai is disabled for self-managed' do
+          include_context 'with experiment features disabled for self-managed'
+
+          it 'returns false if container is not authorized' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+            expect(authorizer.context(context: context).message)
+              .to eq('You do not have access to chat feature.')
+          end
+        end
       end
-    end
 
-    context 'when resource container is authorized' do
-      include_context 'with ai features enabled for group'
+      context 'when only resource is present' do
+        let(:context) do
+          Gitlab::Llm::Chain::GitlabContext.new(
+            current_user: user,
+            container: nil,
+            resource: resource,
+            ai_request: nil
+          )
+        end
+
+        context 'when ai is enabled for self-managed' do
+          include_context 'with experiment features enabled for self-managed'
 
-      it 'calls user.can? with the appropriate arguments' do
-        expect(user).to receive(:can?).with('read_issue', resource)
+          it 'returns true' do
+            expect(authorizer.context(context: context).allowed?).to be(true)
+          end
+        end
 
-        authorizer.resource(resource: context.resource, user: context.current_user)
+        context 'when ai is disabled for self-managed' do
+          include_context 'with experiment features disabled for self-managed'
+
+          it 'returns false' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
+        end
       end
 
-      it 'uses resource from argument' do
-        new_resource = build(:epic)
+      context 'when only container is present' do
+        let(:context) do
+          Gitlab::Llm::Chain::GitlabContext.new(
+            current_user: nil,
+            container: container,
+            resource: nil,
+            ai_request: nil
+          )
+        end
+
+        context 'when ai is enabled for self-managed' do
+          include_context 'with experiment features enabled for self-managed'
+
+          it 'returns false' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
+        end
 
-        expect(new_resource).to receive(:resource_parent).and_return(group)
-        expect(user).to receive(:can?).with('read_epic', new_resource)
+        context 'when ai is disabled for self-managed' do
+          include_context 'with experiment features disabled for self-managed'
 
-        authorizer.resource(resource: new_resource, user: context.current_user)
+          it 'returns false' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
+        end
+      end
+
+      context 'when neither resource nor container is present' do
+        let(:context) do
+          Gitlab::Llm::Chain::GitlabContext.new(
+            current_user: user,
+            container: nil,
+            resource: nil,
+            ai_request: nil
+          )
+        end
+
+        context 'when ai is enabled for self-managed' do
+          include_context 'with experiment features enabled for self-managed'
+
+          it 'returns true' do
+            expect(authorizer.context(context: context).allowed?).to be(true)
+          end
+        end
+
+        context 'when ai is disabled for self-managed' do
+          include_context 'with experiment features disabled for self-managed'
+
+          it 'returns false' do
+            expect(authorizer.context(context: context).allowed?).to be(false)
+          end
+        end
       end
     end
 
-    context 'when resource is current user' do
-      context 'when user is not in any group with ai' do
-        include_context 'with experiment features disabled for group'
+    describe '.resource' do
+      context 'when resource is nil' do
+        let(:resource) { nil }
 
         it 'returns false' do
-          expect(authorizer.resource(resource: context.current_user, user: context.current_user).allowed?)
+          expect(authorizer.resource(resource: context.resource, user: context.current_user).allowed?)
             .to be(false)
         end
       end
 
-      context 'when user is in any group with ai' do
-        include_context 'with ai features enabled for group'
+      context 'when ai is disabled for self-managed' do
+        include_context 'with experiment features disabled for self-managed'
 
-        it 'returns true' do
-          expect(authorizer.resource(resource: context.current_user, user: context.current_user).allowed?)
-            .to be(true)
+        it 'returns false' do
+          expect(authorizer.resource(resource: context.resource, user: context.current_user).allowed?)
+            .to be(false)
         end
+      end
+
+      context 'when ai is enabled for self-managed' do
+        include_context 'with experiment features enabled for self-managed'
+
+        it 'calls user.can? with the appropriate arguments' do
+          expect(user).to receive(:can?).with('read_issue', resource)
+
+          authorizer.resource(resource: context.resource, user: context.current_user)
+        end
+
+        it 'uses resource from argument' do
+          new_resource = build(:epic)
+
+          expect(new_resource).to receive(:resource_parent).and_return(group)
+          expect(user).to receive(:can?).with('read_epic', new_resource)
+
+          authorizer.resource(resource: new_resource, user: context.current_user)
+        end
+      end
 
-        context 'when resource is different user' do
-          let(:resource) { build(:user) }
+      context 'when resource is current user' do
+        context 'when ai is disabled for self-managed' do
+          include_context 'with experiment features disabled for self-managed'
 
           it 'returns false' do
-            expect(authorizer.resource(resource: resource, user: context.current_user).allowed?)
+            expect(authorizer.resource(resource: context.current_user, user: context.current_user).allowed?)
               .to be(false)
           end
         end
+
+        context 'when ai is enabled for self-managed' do
+          include_context 'with experiment features enabled for self-managed'
+
+          it 'returns true' do
+            expect(authorizer.resource(resource: context.current_user, user: context.current_user).allowed?)
+              .to be(true)
+          end
+
+          context 'when resource is different user' do
+            let(:resource) { build(:user) }
+
+            it 'returns false' do
+              expect(authorizer.resource(resource: resource, user: context.current_user).allowed?)
+                .to be(false)
+            end
+          end
+        end
       end
     end
-  end
 
-  describe '.user' do
-    it_behaves_like 'user authorization'
+    describe '.user' do
+      it_behaves_like 'user authorization'
+    end
   end
 end
diff --git a/ee/spec/lib/gitlab/llm/chat_message_spec.rb b/ee/spec/lib/gitlab/llm/chat_message_spec.rb
index fbe1a852bef72..6cbdacc24db06 100644
--- a/ee/spec/lib/gitlab/llm/chat_message_spec.rb
+++ b/ee/spec/lib/gitlab/llm/chat_message_spec.rb
@@ -77,4 +77,10 @@
       end
     end
   end
+
+  describe "#chat?" do
+    it 'returns true for chat message' do
+      expect(subject).to be_chat
+    end
+  end
 end
diff --git a/ee/spec/lib/gitlab/llm/completions/summarize_all_open_notes_spec.rb b/ee/spec/lib/gitlab/llm/completions/summarize_all_open_notes_spec.rb
index c4ef1d3a843c7..f10d25f717e9a 100644
--- a/ee/spec/lib/gitlab/llm/completions/summarize_all_open_notes_spec.rb
+++ b/ee/spec/lib/gitlab/llm/completions/summarize_all_open_notes_spec.rb
@@ -66,7 +66,7 @@
     let(:options) { {} }
 
     let_it_be(:user) { create(:user) }
-    let_it_be(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+    let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
     let_it_be(:project) { create(:project, group: group) }
 
     before_all do
@@ -75,15 +75,17 @@
     end
 
     before do
-      stub_application_setting(check_namespace_plan: true)
-      stub_licensed_features(summarize_notes: true, ai_features: true, epics: true, experimental_features: true)
-
-      group.namespace_settings.update!(
-        experiment_features_enabled: true
-      )
-      project.root_ancestor.update!(
-        experiment_features_enabled: true
+      allow(Gitlab).to receive(:org_or_com?).and_return(true)
+      stub_ee_application_setting(should_check_namespace_plan: true)
+      allow(group.namespace_settings).to receive(:experiment_settings_allowed?).and_return(true)
+      stub_licensed_features(
+        summarize_notes: true,
+        ai_features: true,
+        epics: true,
+        experimental_features: true
       )
+      group.namespace_settings.update!(experiment_features_enabled: true)
+      project.reload
     end
 
     context 'with invalid params' do
diff --git a/ee/spec/lib/gitlab/llm/tanuki_bot_spec.rb b/ee/spec/lib/gitlab/llm/tanuki_bot_spec.rb
index 9369804e8a7b4..f52c477f2a20a 100644
--- a/ee/spec/lib/gitlab/llm/tanuki_bot_spec.rb
+++ b/ee/spec/lib/gitlab/llm/tanuki_bot_spec.rb
@@ -28,125 +28,185 @@
 
     subject(:execute) { instance.execute }
 
-    describe '.enabled_for?', :saas, :use_clean_rails_redis_caching do
-      let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
-
-      context 'when user present and container is not present' do
-        where(:ai_global_switch_enabled, :ai_features_available_to_user, :result) do
-          [
-            [true, true, true],
-            [true, false, false],
-            [false, true, false],
-            [false, false, false]
-          ]
-        end
-
-        with_them do
-          before do
-            stub_feature_flags(ai_global_switch: ai_global_switch_enabled)
-            allow(user).to receive(:any_group_with_ai_available?).and_return(ai_features_available_to_user)
+    describe '.enabled_for?', :use_clean_rails_redis_caching do
+      context 'for saas', :saas do
+        let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+
+        context 'when user present and container is not present' do
+          where(:ai_duo_chat_switch_enabled, :ai_features_available_to_user, :result) do
+            [
+              [true, true, true],
+              [true, false, false],
+              [false, true, false],
+              [false, false, false]
+            ]
           end
 
-          it 'returns correct result' do
-            expect(described_class.enabled_for?(user: user)).to be(result)
+          with_them do
+            before do
+              stub_feature_flags(ai_duo_chat_switch: ai_duo_chat_switch_enabled)
+              allow(user).to receive(:any_group_with_ai_available?).and_return(ai_features_available_to_user)
+            end
+
+            it 'returns correct result' do
+              expect(described_class.enabled_for?(user: user)).to be(result)
+            end
           end
         end
-      end
 
-      context 'when user and container are both present' do
-        context 'when container is a group with AI enabled' do
-          include_context 'with ai features enabled for group'
+        context 'when user and container are both present' do
+          context 'when container is a group with AI enabled' do
+            include_context 'with ai features enabled for group'
 
-          context 'when user is a member of the group' do
-            before_all do
-              group.add_guest(user)
-            end
+            context 'when user is a member of the group' do
+              before_all do
+                group.add_guest(user)
+              end
 
-            context 'when container is a group' do
-              it 'returns true' do
-                expect(
-                  described_class.enabled_for?(user: user, container: group)
-                ).to be(true)
+              context 'when container is a group' do
+                it 'returns true' do
+                  expect(
+                    described_class.enabled_for?(user: user, container: group)
+                  ).to be(true)
+                end
               end
-            end
 
-            context 'when container is a project' do
-              let_it_be(:project) { create(:project, group: group) }
+              context 'when container is a project' do
+                let_it_be(:project) { create(:project, group: group) }
 
-              it 'returns true' do
-                expect(
-                  described_class.enabled_for?(user: user, container: project)
-                ).to be(true)
+                it 'returns true' do
+                  expect(
+                    described_class.enabled_for?(user: user, container: project)
+                  ).to be(true)
+                end
               end
-            end
 
-            context 'when the group does not have an Ultimate SaaS license' do
-              let_it_be(:group) { create(:group) }
+              context 'when the group does not have an Ultimate SaaS license' do
+                let_it_be(:group) { create(:group) }
 
-              it 'returns false' do
-                allow(user).to receive(:any_group_with_ai_available?).and_return(true)
-                # add user as a member of the non-licensed group to ensure the
-                # test isn't failing at the membership check
-                group.add_guest(user)
+                it 'returns false' do
+                  allow(user).to receive(:any_group_with_ai_available?).and_return(true)
+                  # add user as a member of the non-licensed group to ensure the
+                  # test isn't failing at the membership check
+                  group.add_guest(user)
+
+                  expect(
+                    described_class.enabled_for?(user: user, container: group)
+                  ).to be(false)
+                end
+              end
+            end
+
+            context 'when user is not a member of the group' do
+              context 'when the user has AI enabled via another group' do
+                it 'returns false' do
+                  allow(user).to receive(:any_group_with_ai_available?).and_return(true)
 
-                expect(
-                  described_class.enabled_for?(user: user, container: group)
-                ).to be(false)
+                  expect(
+                    described_class.enabled_for?(user: user, container: group)
+                  ).to be(false)
+                end
               end
             end
           end
 
-          context 'when user is not a member of the group' do
-            context 'when the user has AI enabled via another group' do
-              it 'returns false' do
+          context 'when container is not a group with AI enabled' do
+            context 'when user has AI enabled' do
+              before do
                 allow(user).to receive(:any_group_with_ai_available?).and_return(true)
+              end
 
-                expect(
-                  described_class.enabled_for?(user: user, container: group)
-                ).to be(false)
+              context 'when container is a group' do
+                include_context 'with experiment features disabled for group'
+
+                it 'returns false' do
+                  allow(user).to receive(:any_group_with_ai_available?).and_return(true)
+
+                  expect(
+                    described_class.enabled_for?(user: user, container: group)
+                  ).to be(false)
+                end
+              end
+
+              context 'when container is a project in a personal namespace' do
+                let_it_be(:project) { create(:project, namespace: user.namespace) }
+
+                it 'returns false' do
+                  expect(
+                    described_class.enabled_for?(user: user, container: project)
+                  ).to be(false)
+                end
               end
             end
           end
         end
 
-        context 'when container is not a group with AI enabled' do
-          context 'when user has AI enabled' do
+        context 'when user not present, container is present' do
+          include_context 'with ai features enabled for group'
+
+          it 'returns false' do
+            expect(
+              described_class.enabled_for?(user: nil, container: group)
+            ).to be(false)
+          end
+        end
+      end
+
+      context 'for self-managed' do
+        let_it_be_with_reload(:group) { create(:group) }
+
+        let_it_be(:user) { create(:user) }
+
+        context 'for self-managed' do
+          before do
+            allow(Gitlab).to receive(:org_or_com?).and_return(false)
+          end
+
+          context 'with experiments and beta enabled' do
             before do
-              allow(user).to receive(:any_group_with_ai_available?).and_return(true)
+              stub_application_setting(instance_level_ai_beta_features_enabled: true)
             end
 
-            context 'when container is a group' do
-              include_context 'with experiment features disabled for group'
+            context 'without licensed feature' do
+              before do
+                stub_licensed_features(ai_chat: false)
+              end
 
               it 'returns false' do
-                allow(user).to receive(:any_group_with_ai_available?).and_return(true)
-
-                expect(
-                  described_class.enabled_for?(user: user, container: group)
-                ).to be(false)
+                expect(described_class.enabled_for?(user: user)).to eq(false)
               end
             end
 
-            context 'when container is a project in a personal namespace' do
-              let_it_be(:project) { create(:project, namespace: user.namespace) }
+            context 'with licensed feature' do
+              before do
+                stub_licensed_features(ai_chat: true)
+              end
 
-              it 'returns false' do
-                expect(
-                  described_class.enabled_for?(user: user, container: project)
-                ).to be(false)
+              it 'returns true' do
+                expect(described_class.enabled_for?(user: user)).to eq(true)
               end
             end
           end
+
+          context 'with experiments and beta disabled' do
+            before do
+              stub_application_setting(instance_level_ai_beta_features_enabled: false)
+            end
+
+            it 'returns false' do
+              expect(described_class.enabled_for?(user: user)).to eq(false)
+            end
+          end
         end
-      end
 
-      context 'when user not present, container is present' do
-        include_context 'with ai features enabled for group'
+        context 'when user not present, container is present' do
+          include_context 'with experiment features enabled for self-managed'
 
-        it 'returns false' do
-          expect(
-            described_class.enabled_for?(user: nil, container: group)
-          ).to be(false)
+          it 'returns false' do
+            expect(
+              described_class.enabled_for?(user: nil, container: group)
+            ).to be(false)
+          end
         end
       end
     end
@@ -193,7 +253,7 @@
       context 'with the tanuki_bot license available' do
         context 'when on Gitlab.com' do
           before do
-            allow(::Gitlab).to receive(:com?).and_return(true)
+            allow(::Gitlab).to receive(:org_or_com?).and_return(true)
           end
 
           context 'when no user is provided' do
diff --git a/ee/spec/policies/global_policy_spec.rb b/ee/spec/policies/global_policy_spec.rb
index ed0f44d84cd67..97919d2014e69 100644
--- a/ee/spec/policies/global_policy_spec.rb
+++ b/ee/spec/policies/global_policy_spec.rb
@@ -625,6 +625,57 @@
     end
   end
 
+  describe 'access_duo_chat' do
+    let(:policy) { :access_duo_chat }
+
+    let_it_be_with_reload(:current_user) { create(:user) }
+
+    context 'when on .org or .com', :saas do
+      let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+
+      context 'when user is member of a group' do
+        before do
+          group.add_developer(current_user)
+        end
+
+        context 'when group has AI licensing and settings' do
+          include_context 'with ai features enabled for group'
+
+          it { is_expected.to be_allowed(:access_duo_chat) }
+        end
+
+        context 'when group has not AI licensing and settings' do
+          include_context 'with experiment features disabled for group'
+
+          it { is_expected.to be_disallowed(:access_duo_chat) }
+        end
+      end
+
+      context 'when user is not a member of a group' do
+        it { is_expected.to be_disallowed(:access_duo_chat) }
+      end
+    end
+
+    context 'when not on .org or .com' do
+      where(:licensed, :instance_level_ai_beta_features_enabled, :cs_matcher) do
+        true  | false | be_disallowed(policy)
+        true  | true  | be_allowed(policy)
+        false | false | be_disallowed(policy)
+        false | true  | be_disallowed(policy)
+      end
+
+      with_them do
+        before do
+          allow(::Gitlab).to receive(:org_or_com?).and_return(false)
+          stub_ee_application_setting(instance_level_ai_beta_features_enabled: instance_level_ai_beta_features_enabled)
+          stub_licensed_features(ai_chat: licensed)
+        end
+
+        it { is_expected.to cs_matcher }
+      end
+    end
+  end
+
   describe 'git access' do
     context 'security policy bot' do
       let(:current_user) { security_policy_bot }
diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb
index dcb84e5949d7f..51e4edd6558e3 100644
--- a/ee/spec/policies/group_policy_spec.rb
+++ b/ee/spec/policies/group_policy_spec.rb
@@ -2865,6 +2865,86 @@ def expect_private_group_permissions_as_if_non_member
     end
   end
 
+  describe 'access_duo_chat' do
+    let_it_be(:current_user) { create(:user) }
+
+    subject { described_class.new(current_user, group) }
+
+    context 'when on SaaS instance', :saas do
+      let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+
+      context 'when container is a group with AI enabled' do
+        include_context 'with ai features enabled for group'
+
+        context 'when user is a member of the group' do
+          before do
+            group.add_guest(current_user)
+          end
+
+          it { is_expected.to be_allowed(:access_duo_chat) }
+
+          context 'when the group does not have an Ultimate SaaS license' do
+            let_it_be(:group) { create(:group) }
+
+            it { is_expected.to be_disallowed(:access_duo_chat) }
+          end
+        end
+
+        context 'when the user has AI enabled via another group' do
+          it 'is disallowed' do
+            allow(current_user).to receive(:any_group_with_ai_available?).and_return(true)
+
+            is_expected.to be_disallowed(:access_duo_chat)
+          end
+        end
+      end
+
+      context 'when group has not AI enabled' do
+        context 'when user has AI enabled' do
+          before do
+            allow(current_user).to receive(:any_group_with_ai_available?).and_return(true)
+          end
+
+          context 'when container is a group' do
+            include_context 'with experiment features disabled for group'
+
+            it 'returns false' do
+              allow(current_user).to receive(:any_group_with_ai_available?).and_return(true)
+
+              is_expected.to be_disallowed(:access_duo_chat)
+            end
+          end
+        end
+      end
+    end
+
+    context 'for self-managed' do
+      using RSpec::Parameterized::TableSyntax
+
+      let_it_be_with_reload(:group) { create(:group) }
+      let(:policy) { :access_duo_chat }
+
+      context 'when not on .org or .com' do
+        where(:licensed, :instance_level_ai_beta_features_enabled, :cs_matcher) do
+          true  | false | be_disallowed(policy)
+          true  | true  | be_allowed(policy)
+          false | false | be_disallowed(policy)
+          false | true  | be_disallowed(policy)
+        end
+
+        with_them do
+          before do
+            allow(::Gitlab).to receive(:org_or_com?).and_return(false)
+            stub_ee_application_setting(instance_level_ai_beta_features_enabled: instance_level_ai_beta_features_enabled)
+            stub_licensed_features(ai_chat: licensed)
+          end
+
+          it { is_expected.to cs_matcher }
+        end
+      end
+    end
+  end
+
   context 'custom role' do
     let_it_be(:guest) { create(:user) }
     let_it_be(:parent_group) { create(:group) }
diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb
index 9caa4cd833297..35b7aade6870e 100644
--- a/ee/spec/policies/project_policy_spec.rb
+++ b/ee/spec/policies/project_policy_spec.rb
@@ -3338,4 +3338,105 @@ def create_member_role(member, abilities = member_role_abilities)
       end
     end
   end
+
+  describe 'access_duo_chat' do
+    let_it_be(:current_user) { create(:user) }
+    let(:project) { create(:project, group: group) }
+
+    subject { described_class.new(current_user, project) }
+
+    context 'when on SaaS instance', :saas do
+      let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+
+      context 'when container is a group with AI enabled' do
+        include_context 'with ai features enabled for group'
+
+        context 'when user is a member of the group' do
+          before do
+            group.add_guest(current_user)
+          end
+
+          it { is_expected.to be_allowed(:access_duo_chat) }
+
+          context 'when the group does not have an Ultimate SaaS license' do
+            let_it_be(:group) { create(:group) }
+
+            it { is_expected.to be_disallowed(:access_duo_chat) }
+          end
+        end
+
+        context 'when user is not a member of the parent group' do
+          context 'when the user has AI enabled via another group' do
+            it 'is disallowed' do
+              allow(current_user).to receive(:any_group_with_ai_available?).and_return(true)
+
+              is_expected.to be_disallowed(:access_duo_chat)
+            end
+          end
+        end
+
+        context 'when user is a member of the project' do
+          before do
+            project.add_guest(current_user)
+          end
+
+          context 'when the user has AI enabled through parent group' do
+            it 'is allowed' do
+              allow(current_user).to receive(:any_group_with_ai_available?).and_return(true)
+
+              is_expected.to be_allowed(:access_duo_chat)
+            end
+          end
+        end
+      end
+
+      context 'when group has not AI enabled' do
+        context 'when user has AI enabled' do
+          before do
+            allow(current_user).to receive(:any_group_with_ai_available?).and_return(true)
+          end
+
+          context 'when container is a group' do
+            include_context 'with experiment features disabled for group'
+
+            it 'returns false' do
+              allow(current_user).to receive(:any_group_with_ai_available?).and_return(true)
+
+              is_expected.to be_disallowed(:access_duo_chat)
+            end
+          end
+        end
+      end
+    end
+
+    context 'for self-managed' do
+      using RSpec::Parameterized::TableSyntax
+
+      let_it_be_with_reload(:group) { create(:group) }
+      let(:policy) { :access_duo_chat }
+
+      before do
+        project.add_guest(current_user)
+      end
+
+      context 'when not on .org or .com' do
+        where(:licensed, :instance_level_ai_beta_features_enabled, :cs_matcher) do
+          true  | false | be_disallowed(policy)
+          true  | true  | be_allowed(policy)
+          false | false | be_disallowed(policy)
+          false | true  | be_disallowed(policy)
+        end
+
+        with_them do
+          before do
+            allow(::Gitlab).to receive(:org_or_com?).and_return(false)
+            stub_ee_application_setting(instance_level_ai_beta_features_enabled: instance_level_ai_beta_features_enabled)
+            stub_licensed_features(ai_chat: licensed)
+          end
+
+          it { is_expected.to cs_matcher }
+        end
+      end
+    end
+  end
 end
diff --git a/ee/spec/requests/api/graphql/mutations/projects/chat_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/chat_spec.rb
index af44628629eaa..c8345c4bdbd9a 100644
--- a/ee/spec/requests/api/graphql/mutations/projects/chat_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/projects/chat_spec.rb
@@ -78,9 +78,9 @@
     end
   end
 
-  context 'when ai_global_switch feature flag is disabled' do
+  context 'when ai_duo_chat_switch feature flag is disabled' do
     before do
-      stub_feature_flags(ai_global_switch: false)
+      stub_feature_flags(ai_duo_chat_switch: false)
     end
 
     it 'returns nil' do
diff --git a/ee/spec/requests/api/graphql/mutations/projects/explain_code_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/explain_code_spec.rb
index 513bdb7c5774e..6d133252ca8e5 100644
--- a/ee/spec/requests/api/graphql/mutations/projects/explain_code_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/projects/explain_code_spec.rb
@@ -84,7 +84,7 @@
 
       post_graphql_mutation(mutation, current_user: current_user)
 
-      expect(fresh_response_data['errors'][0]['message']).to eq("`ai_global_switch` feature flag is disabled.")
+      expect(fresh_response_data['errors'][0]['message']).to eq("required feature flag is disabled.")
     end
   end
 end
diff --git a/ee/spec/requests/api/graphql/mutations/projects/fill_in_merge_request_template_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/fill_in_merge_request_template_spec.rb
index 02aaae59c1476..ed37567e46f9c 100644
--- a/ee/spec/requests/api/graphql/mutations/projects/fill_in_merge_request_template_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/projects/fill_in_merge_request_template_spec.rb
@@ -69,7 +69,7 @@
 
       post_graphql_mutation(mutation, current_user: current_user)
 
-      expect(fresh_response_data['errors'][0]['message']).to eq("`ai_global_switch` feature flag is disabled.")
+      expect(fresh_response_data['errors'][0]['message']).to eq("required feature flag is disabled.")
     end
   end
 
diff --git a/ee/spec/requests/api/graphql/mutations/projects/generate_commit_message_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/generate_commit_message_spec.rb
index 24d82db62ba59..973a9437484ec 100644
--- a/ee/spec/requests/api/graphql/mutations/projects/generate_commit_message_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/projects/generate_commit_message_spec.rb
@@ -55,7 +55,7 @@
 
       post_graphql_mutation(mutation, current_user: current_user)
 
-      expect(fresh_response_data['errors'][0]['message']).to eq("`ai_global_switch` feature flag is disabled.")
+      expect(fresh_response_data['errors'][0]['message']).to eq("required feature flag is disabled.")
     end
   end
 
diff --git a/ee/spec/requests/api/graphql/mutations/projects/generate_test_file_spec.rb b/ee/spec/requests/api/graphql/mutations/projects/generate_test_file_spec.rb
index 8a299aa8f3a5b..ada08c1ca62de 100644
--- a/ee/spec/requests/api/graphql/mutations/projects/generate_test_file_spec.rb
+++ b/ee/spec/requests/api/graphql/mutations/projects/generate_test_file_spec.rb
@@ -68,7 +68,7 @@
 
       post_graphql_mutation(mutation, current_user: current_user)
 
-      expect(fresh_response_data['errors'][0]['message']).to eq("`ai_global_switch` feature flag is disabled.")
+      expect(fresh_response_data['errors'][0]['message']).to eq("required feature flag is disabled.")
     end
   end
 
diff --git a/ee/spec/services/llm/base_service_spec.rb b/ee/spec/services/llm/base_service_spec.rb
index 983336b1775d5..4973c23e3fda9 100644
--- a/ee/spec/services/llm/base_service_spec.rb
+++ b/ee/spec/services/llm/base_service_spec.rb
@@ -2,11 +2,9 @@
 
 require 'spec_helper'
 
-RSpec.describe Llm::BaseService, :saas, feature_category: :ai_abstraction_layer do
-  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+RSpec.describe Llm::BaseService, feature_category: :ai_abstraction_layer do
   let_it_be(:user) { create(:user) }
-  let_it_be(:project) { create(:project, group: group) }
-  let_it_be(:resource) { create(:issue, project: project) }
+
   let(:options) { {} }
 
   subject { described_class.new(user, resource, options) }
@@ -52,52 +50,66 @@ def ai_action
     end
   end
 
-  context 'when user has no access' do
-    it_behaves_like 'returns an error'
-  end
+  context 'for SaaS instance', :saas do
+    let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+    let_it_be(:project) { create(:project, group: group) }
+    let_it_be(:resource) { create(:issue, project: project) }
 
-  context 'when user has access' do
-    before do
-      project.add_developer(user)
-      group.add_developer(user)
+    context 'when user has no access' do
+      it_behaves_like 'returns an error'
     end
 
-    context 'when ai_global_switch feature flag is not enabled' do
+    context 'when user has access' do
       before do
-        stub_feature_flags(ai_global_switch: false)
+        project.add_developer(user)
+        group.add_developer(user)
       end
 
-      it_behaves_like 'returns an error'
-    end
+      context 'when ai_global_switch feature flag is not enabled' do
+        before do
+          stub_feature_flags(ai_global_switch: false)
+        end
 
-    context 'when experimental features are disabled for the group' do
-      include_context 'with experiment features disabled for group'
+        it_behaves_like 'returns an error'
+      end
 
-      it_behaves_like 'returns an error'
-    end
+      context 'when experimental features are disabled for the group' do
+        include_context 'with experiment features disabled for group'
 
-    context 'when ai features are enabled' do
-      include_context 'with ai features enabled for group'
+        it_behaves_like 'returns an error'
+      end
 
-      it_behaves_like 'raises a NotImplementedError'
+      context 'when ai features are enabled' do
+        include_context 'with ai features enabled for group'
 
-      context 'when resource is an issue' do
-        let_it_be(:resource) { create(:issue, project: project) }
+        it_behaves_like 'raises a NotImplementedError'
 
-        it_behaves_like 'success when implemented'
-      end
+        context 'when resource is an issue' do
+          let_it_be(:resource) { create(:issue, project: project) }
 
-      context 'when resource is a user' do
-        let_it_be(:resource) { user }
+          it_behaves_like 'success when implemented'
+        end
 
-        it_behaves_like 'success when implemented'
-      end
+        context 'when resource is a user' do
+          let_it_be(:resource) { user }
+
+          it_behaves_like 'success when implemented'
+        end
 
-      context 'when resource is nil' do
-        let_it_be(:resource) { nil }
+        context 'when resource is nil' do
+          let_it_be(:resource) { nil }
 
-        it_behaves_like 'success when implemented'
+          it_behaves_like 'success when implemented'
+        end
       end
     end
   end
+
+  context 'for self-managed instance' do
+    let_it_be(:group) { create(:group) }
+    let_it_be(:project) { create(:project, group: group) }
+    let_it_be(:resource) { create(:issue, project: project) }
+
+    it_behaves_like 'returns an error'
+  end
 end
diff --git a/ee/spec/services/llm/chat_service_spec.rb b/ee/spec/services/llm/chat_service_spec.rb
index cf577118031e1..741f0c49cb536 100644
--- a/ee/spec/services/llm/chat_service_spec.rb
+++ b/ee/spec/services/llm/chat_service_spec.rb
@@ -2,26 +2,26 @@
 
 require 'spec_helper'
 
-RSpec.describe Llm::ChatService, :saas, feature_category: :shared do
-  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+RSpec.describe Llm::ChatService, feature_category: :duo_chat do
   let_it_be(:user) { create(:user) }
-  let_it_be(:project) { create(:project, group: group) }
-  let_it_be(:issue) { create(:issue, project: project) }
 
   let(:resource) { issue }
   let(:stage_check_available) { true }
   let(:content) { "Summarize issue" }
   let(:options) { { content: content } }
 
-  subject { described_class.new(user, resource, options) }
+  context 'for self-managed' do
+    let_it_be_with_reload(:group) { create(:group) }
+    let_it_be(:project) { create(:project, group: group) }
+    let_it_be(:issue) { create(:issue, project: project) }
 
-  describe '#perform' do
-    context 'when ai features are enabled for the group' do
-      include_context 'with ai features enabled for group'
+    subject { described_class.new(user, resource, options) }
+
+    context 'when ai features are enabled for instance' do
+      include_context 'with experiment features enabled for self-managed'
 
       before do
         allow(SecureRandom).to receive(:uuid).and_return('uuid')
-        allow(Gitlab::Llm::StageCheck).to receive(:available?).with(group, :chat).and_return(stage_check_available)
       end
 
       context 'when user is part of the group' do
@@ -57,5 +57,66 @@
         end
       end
     end
+
+    context 'when ai features are disabled for instance' do
+      include_context 'with experiment features disabled for self-managed'
+
+      it 'returns an error' do
+        expect(Llm::CompletionWorker).not_to receive(:perform_for)
+        expect(subject.execute).to be_error
+      end
+    end
+  end
+
+  context 'for saas', :saas do
+    let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
+    let_it_be(:project) { create(:project, group: group) }
+    let_it_be(:issue) { create(:issue, project: project) }
+
+    subject { described_class.new(user, resource, options) }
+
+    describe '#perform' do
+      context 'when ai features are enabled for the group' do
+        include_context 'with ai features enabled for group'
+
+        before do
+          allow(SecureRandom).to receive(:uuid).and_return('uuid')
+          allow(Gitlab::Llm::StageCheck).to receive(:available?).with(group, :chat).and_return(stage_check_available)
+        end
+
+        context 'when user is part of the group' do
+          before do
+            group.add_developer(user)
+          end
+
+          context 'when resource is an issue' do
+            let(:resource) { issue }
+            let(:action_name) { :chat }
+            let(:content) { 'Summarize issue' }
+
+            it_behaves_like 'schedules completion worker'
+            it_behaves_like 'llm service caches user request'
+            it_behaves_like 'service emitting message for user prompt'
+          end
+
+          context 'when resource is a user' do
+            let(:resource) { user }
+            let(:action_name) { :chat }
+            let(:content) { 'How to reset the password' }
+
+            it_behaves_like 'schedules completion worker'
+            it_behaves_like 'llm service caches user request'
+            it_behaves_like 'service emitting message for user prompt'
+          end
+        end
+
+        context 'when user is not part of the group' do
+          it 'returns an error' do
+            expect(Llm::CompletionWorker).not_to receive(:perform_for)
+            expect(subject.execute).to be_error
+          end
+        end
+      end
+    end
   end
 end
diff --git a/ee/spec/services/llm/fill_in_merge_request_template_service_spec.rb b/ee/spec/services/llm/fill_in_merge_request_template_service_spec.rb
index ec92d8b7e5250..fc3fd06e2f016 100644
--- a/ee/spec/services/llm/fill_in_merge_request_template_service_spec.rb
+++ b/ee/spec/services/llm/fill_in_merge_request_template_service_spec.rb
@@ -2,15 +2,17 @@
 
 require 'spec_helper'
 
-RSpec.describe Llm::FillInMergeRequestTemplateService, feature_category: :code_review_workflow do
+RSpec.describe Llm::FillInMergeRequestTemplateService, :saas, feature_category: :code_review_workflow do
   let_it_be(:user) { create(:user) }
-  let_it_be(:group) { create(:group, :public) }
+  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
   let_it_be(:resource) { create(:project, :public, group: group) }
 
   let(:fill_in_merge_request_template_enabled) { true }
   let(:current_user) { user }
 
   describe '#perform' do
+    include_context 'with ai features enabled for group'
+
     before do
       group.add_guest(user)
 
diff --git a/ee/spec/services/llm/generate_description_service_spec.rb b/ee/spec/services/llm/generate_description_service_spec.rb
index 0ce43e8e91089..233a56745a0a5 100644
--- a/ee/spec/services/llm/generate_description_service_spec.rb
+++ b/ee/spec/services/llm/generate_description_service_spec.rb
@@ -2,9 +2,9 @@
 
 require 'spec_helper'
 
-RSpec.describe Llm::GenerateDescriptionService, feature_category: :team_planning do
+RSpec.describe Llm::GenerateDescriptionService, :saas, feature_category: :team_planning do
   let_it_be(:user) { create(:user) }
-  let_it_be(:group) { create(:group, :public) }
+  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
   let_it_be(:project) { create(:project, :public, group: group) }
   let_it_be(:resource) { create(:issue, project: project) }
 
@@ -14,6 +14,8 @@
   let(:generate_description_license_enabled) { true }
 
   describe '#perform' do
+    include_context 'with ai features enabled for group'
+
     before do
       stub_licensed_features(generate_description: true)
       group.add_guest(user)
diff --git a/ee/spec/services/llm/generate_summary_service_spec.rb b/ee/spec/services/llm/generate_summary_service_spec.rb
index 905b66ff3d0ad..0e313157b5cab 100644
--- a/ee/spec/services/llm/generate_summary_service_spec.rb
+++ b/ee/spec/services/llm/generate_summary_service_spec.rb
@@ -2,9 +2,9 @@
 
 require 'spec_helper'
 
-RSpec.describe Llm::GenerateSummaryService, feature_category: :ai_abstraction_layer do
+RSpec.describe Llm::GenerateSummaryService, :saas, feature_category: :ai_abstraction_layer do
   let_it_be(:user) { create(:user) }
-  let_it_be(:group) { create(:group, :public) }
+  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
   let_it_be(:project) { create(:project, :public, group: group) }
 
   let(:options) { {} }
@@ -13,6 +13,8 @@
   let(:current_user) { user }
 
   describe '#perform' do
+    include_context 'with ai features enabled for group'
+
     before do
       group.add_guest(user)
 
diff --git a/ee/spec/services/llm/internal/completion_service_spec.rb b/ee/spec/services/llm/internal/completion_service_spec.rb
index 7fc6da3d39896..22123f8066854 100644
--- a/ee/spec/services/llm/internal/completion_service_spec.rb
+++ b/ee/spec/services/llm/internal/completion_service_spec.rb
@@ -114,6 +114,20 @@
 
         it_behaves_like 'performs successfully'
       end
+
+      context 'when it is chat request' do
+        let_it_be(:resource) { nil }
+        let(:ai_action_name) { :chat }
+        let(:prompt_message) do
+          build(:ai_chat_message, user: user, resource: resource, ai_action: ai_action_name, request_id: 'uuid')
+        end
+
+        before do
+          stub_feature_flags(ai_duo_chat_switch: true, ai_global_switch: false)
+        end
+
+        it_behaves_like 'performs successfully'
+      end
     end
 
     context 'with service failure' do
diff --git a/ee/spec/services/llm/summarize_merge_request_service_spec.rb b/ee/spec/services/llm/summarize_merge_request_service_spec.rb
index 218c74d362cb3..9493e1e5d13a1 100644
--- a/ee/spec/services/llm/summarize_merge_request_service_spec.rb
+++ b/ee/spec/services/llm/summarize_merge_request_service_spec.rb
@@ -2,9 +2,9 @@
 
 require 'spec_helper'
 
-RSpec.describe Llm::SummarizeMergeRequestService, feature_category: :code_review_workflow do
+RSpec.describe Llm::SummarizeMergeRequestService, :saas, feature_category: :code_review_workflow do
   let_it_be(:user) { create(:user) }
-  let_it_be(:group) { create(:group, :public) }
+  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
   let_it_be(:project) { create(:project, :public, group: group) }
   let_it_be(:resource) { create(:merge_request, source_project: project, target_project: project, author: user) }
 
@@ -13,6 +13,8 @@
   let(:options) { {} }
 
   describe '#perform' do
+    include_context 'with ai features enabled for group'
+
     let(:action_name) { :summarize_merge_request }
     let(:content) { 'Summarize merge request' }
 
diff --git a/ee/spec/services/llm/summarize_submitted_review_service_spec.rb b/ee/spec/services/llm/summarize_submitted_review_service_spec.rb
index 48c3fdc0a96a6..dd52b85394840 100644
--- a/ee/spec/services/llm/summarize_submitted_review_service_spec.rb
+++ b/ee/spec/services/llm/summarize_submitted_review_service_spec.rb
@@ -2,9 +2,9 @@
 
 require 'spec_helper'
 
-RSpec.describe Llm::SummarizeSubmittedReviewService, feature_category: :code_review_workflow do
+RSpec.describe Llm::SummarizeSubmittedReviewService, :saas, feature_category: :code_review_workflow do
   let_it_be(:user) { create(:user) }
-  let_it_be(:group) { create(:group, :public) }
+  let_it_be_with_reload(:group) { create(:group_with_plan, plan: :ultimate_plan) }
   let_it_be(:project) { create(:project, :public, group: group) }
   let_it_be(:resource) { create(:merge_request, source_project: project, target_project: project, author: user) }
 
@@ -13,6 +13,8 @@
   let(:options) { {} }
 
   describe '#perform' do
+    include_context 'with ai features enabled for group'
+
     let(:action_name) { :summarize_submitted_review }
     let(:content) { 'Summarize submitted review' }
 
diff --git a/ee/spec/support/shared_contexts/services/llm/ai_features_enabled_for_group_shared_context.rb b/ee/spec/support/shared_contexts/services/llm/ai_features_enabled_for_group_shared_context.rb
index f3e5242a85830..1ddc3402db133 100644
--- a/ee/spec/support/shared_contexts/services/llm/ai_features_enabled_for_group_shared_context.rb
+++ b/ee/spec/support/shared_contexts/services/llm/ai_features_enabled_for_group_shared_context.rb
@@ -2,7 +2,7 @@
 
 RSpec.shared_context 'with ai features enabled for group' do
   before do
-    allow(Gitlab).to receive(:com?).and_return(true)
+    allow(Gitlab).to receive(:org_or_com?).and_return(true)
     stub_ee_application_setting(should_check_namespace_plan: true)
     allow(group.namespace_settings).to receive(:experiment_settings_allowed?).and_return(true)
     stub_licensed_features(
@@ -16,7 +16,7 @@
 
 RSpec.shared_context 'with experiment features disabled for group' do
   before do
-    allow(Gitlab).to receive(:com?).and_return(true)
+    allow(Gitlab).to receive(:org_or_com?).and_return(true)
     stub_ee_application_setting(should_check_namespace_plan: true)
     allow(group.namespace_settings).to receive(:experiment_settings_allowed?).and_return(true)
     stub_licensed_features(
@@ -27,3 +27,19 @@
     group.namespace_settings.update!(experiment_features_enabled: false)
   end
 end
+
+RSpec.shared_context 'with experiment features enabled for self-managed' do
+  before do
+    allow(Gitlab).to receive(:org_or_com?).and_return(false)
+    stub_application_setting(instance_level_ai_beta_features_enabled: true)
+    stub_licensed_features(ai_chat: true)
+  end
+end
+
+RSpec.shared_context 'with experiment features disabled for self-managed' do
+  before do
+    allow(Gitlab).to receive(:org_or_com?).and_return(false)
+    stub_application_setting(instance_level_ai_beta_features_enabled: false)
+    stub_licensed_features(ai_chat: true)
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 06531b63163fc..b396e6da18223 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -56317,10 +56317,10 @@ msgstr ""
 msgid "You do not belong to any projects yet."
 msgstr ""
 
-msgid "You do not have access to AI features."
+msgid "You do not have access to any projects for creating incidents."
 msgstr ""
 
-msgid "You do not have access to any projects for creating incidents."
+msgid "You do not have access to chat feature."
 msgstr ""
 
 msgid "You do not have any subscriptions yet"
-- 
GitLab