diff --git a/config/audit_events/types/self_hosted_model_created.yml b/config/audit_events/types/self_hosted_model_created.yml new file mode 100644 index 0000000000000000000000000000000000000000..fa6203a73b23e1edd1593b9b3f9753d1b825c225 --- /dev/null +++ b/config/audit_events/types/self_hosted_model_created.yml @@ -0,0 +1,9 @@ +name: self_hosted_model_created +description: A new self-hosted model configuration was added +introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/477999 +introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165303 +feature_category: self-hosted_models +milestone: '17.4' +saved_to_database: true +scope: [Instance, User] +streamed: true diff --git a/doc/user/compliance/audit_event_types.md b/doc/user/compliance/audit_event_types.md index 4cc67e31c3fca678b172cf661eec7c4023972359..1394f40f3f423590768502bad064d4b61f121e24 100644 --- a/doc/user/compliance/audit_event_types.md +++ b/doc/user/compliance/audit_event_types.md @@ -453,6 +453,7 @@ Audit event types belong to the following product categories. | Name | Description | Saved to database | Streamed | Introduced in | Scope | |:------------|:------------|:------------------|:---------|:--------------|:--------------| +| [`self_hosted_model_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165303) | A new self-hosted model configuration was added | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.4](https://gitlab.com/gitlab-org/gitlab/-/issues/477999) | Instance, User | | [`self_hosted_model_destroyed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165321) | A new self-hosted model configuration was destroyed | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.4](https://gitlab.com/gitlab-org/gitlab/-/issues/477999) | Instance, User | | [`self_hosted_model_feature_changed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165489) | A self-hosted model feature had its configuration changed | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.4](https://gitlab.com/gitlab-org/gitlab/-/issues/463215) | Project | | [`self_hosted_model_terms_accepted`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165480) | Terms for usage of self-hosted models were accepted | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.4](https://gitlab.com/gitlab-org/gitlab/-/issues/477999) | Instance, User | diff --git a/ee/app/controllers/admin/ai/self_hosted_models_controller.rb b/ee/app/controllers/admin/ai/self_hosted_models_controller.rb index 50463201f8e6287a216bc8c5e1393e615898762f..6bb87a3ceb9496f48f7b6eaaacdcf23723b2c419 100644 --- a/ee/app/controllers/admin/ai/self_hosted_models_controller.rb +++ b/ee/app/controllers/admin/ai/self_hosted_models_controller.rb @@ -20,9 +20,10 @@ def new end def create - @self_hosted_model = ::Ai::SelfHostedModel.create(self_hosted_models_params) + service_result = ::Ai::SelfHostedModels::CreateService.new(current_user, self_hosted_models_params).execute - if @self_hosted_model.persisted? + if service_result.success? + @self_hosted_model = service_result.payload redirect_to admin_ai_self_hosted_models_url, notice: _("Self-Hosted Model was created") else render :new diff --git a/ee/app/graphql/mutations/ai/self_hosted_models/create.rb b/ee/app/graphql/mutations/ai/self_hosted_models/create.rb index 57599ac73955e7431af25198c2ea32aa660d8bd5..db6068b35f800d0de11d37d6290b66b7b405a96b 100644 --- a/ee/app/graphql/mutations/ai/self_hosted_models/create.rb +++ b/ee/app/graphql/mutations/ai/self_hosted_models/create.rb @@ -25,23 +25,19 @@ class Create < Base def resolve(**args) check_feature_access! - # TODO: We should create a service this is done for MVP sake - model = ::Ai::SelfHostedModel.create!( - name: args[:name], - model: args[:model], - endpoint: args[:endpoint], - api_token: args[:api_token] - ) - - { - self_hosted_model: model, - errors: [] # Errors are rescued below - } - rescue ActiveRecord::RecordInvalid => e - { - self_hosted_model: nil, - errors: [e.message] - } + result = ::Ai::SelfHostedModels::CreateService.new(current_user, args).execute + + if result.success? + { + self_hosted_model: result.payload, + errors: [] # Errors are rescued below + } + else + { + self_hosted_model: nil, + errors: [result.message] + } + end end end end diff --git a/ee/app/services/ai/self_hosted_models/create_service.rb b/ee/app/services/ai/self_hosted_models/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..eee98bd1d2ba9bf77a5174b92bb903a87109a104 --- /dev/null +++ b/ee/app/services/ai/self_hosted_models/create_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Ai + module SelfHostedModels + class CreateService + def initialize(current_user, params) + @params = params + @user = current_user + end + + def execute + @self_hosted_model = ::Ai::SelfHostedModel.new(params) + + if @self_hosted_model.save + audit_creation_event(@self_hosted_model) + + ServiceResponse.success(payload: @self_hosted_model) + else + ServiceResponse.error(message: @self_hosted_model.errors.full_messages.join(", ")) + end + end + + private + + def audit_creation_event(model) + audit_context = { + name: 'self_hosted_model_created', + author: user, + scope: user, + target: model, + message: "Self-hosted model #{model.name}/#{model.model}/#{model.endpoint} created" + } + + ::Gitlab::Audit::Auditor.audit(audit_context) + end + + attr_accessor :user, :params + end + end +end diff --git a/ee/spec/requests/api/graphql/ai/self_hosted_models/create_spec.rb b/ee/spec/requests/api/graphql/ai/self_hosted_models/create_spec.rb index 1f9be3d8c2ca2cef253467f22ccdc0a22bb59a71..a6dab4ee908d63f72d272b25ac55daa3fad4dd12 100644 --- a/ee/spec/requests/api/graphql/ai/self_hosted_models/create_spec.rb +++ b/ee/spec/requests/api/graphql/ai/self_hosted_models/create_spec.rb @@ -64,7 +64,7 @@ post_graphql_mutation(mutation, current_user: current_user) expect(mutation_response['selfHostedModel']).to be(nil) - expect(mutation_response['errors']).to include("Validation failed: Name can't be blank") + expect(mutation_response['errors']).to include("Name can't be blank") end it 'does not create a self-hosted model' do diff --git a/ee/spec/services/ai/self_hosted_models/create_service_spec.rb b/ee/spec/services/ai/self_hosted_models/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..b121aef26ba2cc527bd79294a71be0d3b79831ed --- /dev/null +++ b/ee/spec/services/ai/self_hosted_models/create_service_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Ai::SelfHostedModels::CreateService, feature_category: :"self-hosted_models" do + let(:user) { create(:user) } + let(:base_params) do + { + name: 'Test Model', + model: 'codestral', + endpoint: 'https://api.example.com', + api_token: 'test_token' + } + end + + let(:params) { base_params } + let(:service) { described_class.new(user, params) } + + before do + allow(Gitlab::Audit::Auditor).to receive(:audit).and_call_original + end + + describe '#execute', :aggregate_failures do + subject(:result) { service.execute } + + let(:model) { result.payload } + let(:audit_event) do + { + name: 'self_hosted_model_created', + author: user, + scope: user, + target: model, + message: "Self-hosted model #{params[:name]}/#{params[:model]}/#{params[:endpoint]} created" + } + end + + context 'when params are valid' do + it 'creates the model' do + expect { result }.to change { ::Ai::SelfHostedModel.count }.by(1) + expect(result).to be_success + + expect(model.name).to eq('Test Model') + expect(model.model).to eq('codestral') + expect(model.endpoint).to eq('https://api.example.com') + expect(model.api_token).to eq('test_token') + + expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_event) + end + end + + context 'when model is invalid' do + let(:params) { base_params.merge(model: 'invalid_model') } + + it 'raises error' do + expect { result }.to raise_error(ArgumentError, /'invalid_model' is not a valid model/) + + expect(Gitlab::Audit::Auditor).not_to receive(:audit).with(hash_including(name: "self_hosted_model_created")) + end + end + + context 'when params are invalid' do + let(:params) { base_params.merge(name: '', endpoint: 'not_a_url') } + + it 'returns an error response' do + expect { result }.not_to change { ::Ai::SelfHostedModel.count } + + expect(result).to be_error + expect(result.message).to include("Name can't be blank") + expect(result.message).to include("Endpoint is blocked: Only allowed schemes are http, https") + + expect(Gitlab::Audit::Auditor).not_to receive(:audit).with(hash_including(name: "self_hosted_model_created")) + end + end + end +end