Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow users to configure a personal OpenAI API key #1496

Merged
merged 13 commits into from
Aug 1, 2024
Merged
4 changes: 3 additions & 1 deletion app/controllers/tasks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,10 @@
# rubocop:enable Metrics/AbcSize

def generate_test
TaskService::GptGenerateTests.call(task: @task)
TaskService::GptGenerateTests.call(task: @task, openai_api_key: current_user.openai_api_key)
flash[:notice] = I18n.t('tasks.task_service.gpt_generate_tests.successful_generation')
rescue Gpt::InvalidApiKeyError
flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.invalid_api_key')

Check warning on line 193 in app/controllers/tasks_controller.rb

View check run for this annotation

Codecov / codecov/patch

app/controllers/tasks_controller.rb#L193

Added line #L193 was not covered by tests
rescue Gpt::MissingLanguageError
flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.no_language')
rescue Gpt::InvalidTaskDescription
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def configure_sign_up_params

# If you have extra params to permit, append them to the sanitizer.
def configure_account_update_params
devise_parameter_sanitizer.permit(:account_update, keys: %i[first_name last_name avatar])
devise_parameter_sanitizer.permit(:account_update, keys: %i[first_name last_name avatar openai_api_key])
end

def after_update_path_for(resource)
Expand Down
5 changes: 5 additions & 0 deletions app/errors/gpt/invalid_api_key_error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

module Gpt
class InvalidApiKeyError < StandardError; end
end
14 changes: 14 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class User < ApplicationRecord
validates :email, presence: true, uniqueness: {case_sensitive: false}
validates :first_name, :last_name, :status_group, presence: true
validates :password_set, inclusion: [true, false]
validate :validate_openai_api_key, if: -> { openai_api_key.present? }

has_many :tasks, dependent: :nullify

Expand Down Expand Up @@ -146,6 +147,19 @@ def to_s
name
end

def validate_openai_api_key
return unless openai_api_key_changed?

client = OpenAI::Client.new(access_token: openai_api_key)

begin
response = client.models.list
errors.add(:base, :invalid_api_key) unless response['data']
rescue Faraday::UnauthorizedError, OpenAI::Error
errors.add(:base, :invalid_api_key)
end
MrSerth marked this conversation as resolved.
Show resolved Hide resolved
end

private

def avatar_format
Expand Down
2 changes: 1 addition & 1 deletion app/policies/task_policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ def manage?
end

def generate_test?
Settings.open_ai.access_token.present? and update?
user&.openai_api_key.present? and update?
end

private
Expand Down
19 changes: 17 additions & 2 deletions app/services/task_service/gpt_generate_tests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

module TaskService
class GptGenerateTests < ServiceBase
def initialize(task:)
def initialize(task:, openai_api_key:)
super()
raise Gpt::MissingLanguageError if task.programming_language&.language.blank?

@task = task
@openai_api_key = openai_api_key
validate_api_key!
end

def execute
Expand All @@ -21,7 +23,7 @@ def execute
private

def client
@client ||= OpenAI::Client.new
@client ||= OpenAI::Client.new(access_token: @openai_api_key)
end

def gpt_response
Expand Down Expand Up @@ -64,5 +66,18 @@ def training_prompts
PROMPT
]
end

def validate_api_key!
if @openai_api_key.blank?
raise Gpt::InvalidApiKeyError
else
begin
response = client.models.list
raise Gpt::InvalidApiKeyError unless response['data']
rescue Faraday::UnauthorizedError, OpenAI::Error
raise Gpt::InvalidApiKeyError
end
MrSerth marked this conversation as resolved.
Show resolved Hide resolved
end
end
end
end
13 changes: 11 additions & 2 deletions app/views/tasks/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -574,9 +574,18 @@
=< t('common.button.duplicate')

- if policy(@task).generate_test?
Melhaya marked this conversation as resolved.
Show resolved Hide resolved
MrSerth marked this conversation as resolved.
Show resolved Hide resolved
= link_to generate_test_task_path(@task), method: :post, class: 'btn btn-important' do
= link_to generate_test_task_path(@task),
method: :post,
class: 'btn btn-important' do
i.fa-solid.fa-wand-magic-sparkles
=< t('.button.generate_test')
= t('.button.generate_test')
- elsif policy(@task).update?
div data-bs-toggle='tooltip' title=t('.button.api_key_required') data-bs-delay=150
= link_to generate_test_task_path(@task),
method: :post,
class: 'btn btn-important disabled' do
i.fa-solid.fa-wand-magic-sparkles
= t('.button.generate_test')

- if current_user.present?
= link_to t('common.button.back'), tasks_path, class: 'btn btn-important'
Expand Down
10 changes: 10 additions & 0 deletions app/views/users/registrations/edit.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@
autocomplete: 'new-password',
class: 'form-control'

.form-group.field-element
MrSerth marked this conversation as resolved.
Show resolved Hide resolved
= f.label :openai_api_key, t('users.show.openai_api_key'), class: 'form-label'
= f.text_field :openai_api_key,
required: false,
placeholder: t('users.show.openai_api_key'),
autocomplete: 'off',
class: 'form-control'
small.form-text.text-body-secondary
= t('.openai_api_key_usage_html', openai_api_link: 'https://platform.openai.com/api-keys')

= render 'avatar_form', f:

- if resource.password_set?
Expand Down
9 changes: 9 additions & 0 deletions app/views/users/show.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@
| :
.col.row-value
= @user.email
.row
.col-auto.row-label
= t('.openai_api_key')
| :
.col.row-value
- if @user.openai_api_key.present?
= t('.openai_api_key_provided')
- else
= t('.openai_api_key_not_provided')
.row.vertical
.col.row-label
= t('.account_links.created')
Expand Down
7 changes: 0 additions & 7 deletions config/initializers/open_ai.rb

This file was deleted.

1 change: 1 addition & 0 deletions config/locales/de/controllers/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ de:
task_found: 'In der externen App wurde eine entsprechende Aufgabe gefunden. Sie können: <ul><li><b>Überschreiben</b> Sie die Aufgabe in der externen App. Dadurch werden alle Änderungen, die in CodeHarbor vorgenommen wurden, auf die externe App übertragen.<br>Vorsicht: Dadurch werden alle potenziellen Änderungen in der externen App überschrieben. Dadurch wird die Aufgabe geändert (und möglicherweise zerstört), auch wenn sie derzeit in einem Kurs verwendet wird.</li><li><b>Erstellen Sie eine neue</b> Aufgabe, die den aktuellen Zustand dieser Aufgabe kopiert. Dadurch wird eine Kopie dieser Aufgabe in CodeHarbor erstellt, die dann als völlig neue Aufgabe in der externen App exportiert wird.</li></ul>'
task_found_no_right: 'In der externen App wurde eine entsprechende Aufgabe gefunden, aber Sie haben keine Rechte, sie zu bearbeiten. Sie können: <ul><li><b>Eine neue Aufgabe erstellen</b>, die den aktuellen Zustand dieser Aufgabe kopiert. Dadurch wird eine Kopie dieser Aufgabe in CodeHarbor erstellt, die dann als völlig neue Aufgabe in der externen App exportiert wird.</li></ul>'
gpt_generate_tests:
invalid_api_key: Der API-Schlüssel in Ihrem Profil ist ungültig. Bitte fügen Sie den entsprechenden API-Schlüssel in Ihrem Profil hinzu, um die KI-Funktionen zu nutzen.
invalid_description: Die angegebene Aufgabenbeschreibung stellt keine gültige Programmieraufgabe dar und kann daher nicht zum Generieren eines Unit-Tests genutzt werden. Bitte stellen Sie sicher, dass die Aufgabenbeschreibung eine klar formulierte Problemstellung enthält, die durch ein Programm gelöst werden kann.
no_language: Für diese Aufgabe ist keine Programmiersprache angegeben. Bitte geben Sie die Sprache an, bevor Sie fortfahren.
successful_generation: Unit-Test erfolgreich generiert. Bitte überprüfen Sie den generierten Test und vergeben Sie einen passenden Dateinamen.
1 change: 1 addition & 0 deletions config/locales/de/models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ de:
avatar:
not_an_image: muss ein Bild sein
size_over_10_mb: Größe muss kleiner als 10MB sein
invalid_api_key: Der API-Schlüssel in Ihrem Profil ist ungültig. Bitte fügen Sie den entsprechenden API-Schlüssel in Ihrem Profil hinzu, um die KI-Funktionen zu nutzen.
models:
account_link:
one: Account-Link
Expand Down
1 change: 1 addition & 0 deletions config/locales/de/views/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ de:
add_to_collection_hint: Speichern Sie Aufgaben für später, indem Sie sie zu einer Sammlung hinzufügen.
button:
add_to_collection: Zu Sammlung hinzufügen
api_key_required: OpenAI API-Schlüssel ist erforderlich, um einen Test zu erstellen
create_collection: Neue Sammlung anlegen
download_as_zip: Diese Aufgabe als ZIP-Datei herunterladen.
export: Exportieren
Expand Down
4 changes: 4 additions & 0 deletions config/locales/de/views/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ de:
add_identity: Account mit %{kind} verknüpfen
cannot_remove_last_identity: Account-Verknüpfung zu %{kind} kann nicht entfernt werden, da weder ein Passwort gesetzt noch eine andere Identität verknüpft ist
manage_omniauth: Verknüpfte Accounts verwalten
openai_api_key_usage_html: Geben Sie hier Ihren OpenAI-API-Schlüssel ein, um verschiedene KI-Funktionen innerhalb der Plattform zu nutzen. Sie können einen API-Schlüssel auf der <a href='%{openai_api_link}' target='_blank' rel='noopener noreferrer'>OpenAI-Website</a> erstellen.
remove_identity: Account-Verknüpfung zu %{kind} entfernen
shared:
notification_modal:
Expand All @@ -51,6 +52,9 @@ de:
delete_modal:
title: Warnung
full_name: Vollständiger Name
openai_api_key: OpenAI API-Schlüssel
openai_api_key_not_provided: Nicht eingegeben
openai_api_key_provided: Eingegeben
private_information: Private Informationen
public_information: Öffentliche Informationen
send_message: Nachricht senden
1 change: 1 addition & 0 deletions config/locales/en/controllers/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ en:
task_found: 'A corresponding task has been found on the external app. You can: <ul><li><b>Overwrite</b> the task on the external app. This will transfer all changes made on CodeHarbor to the external app.<br>Careful: This will overwrite all potential changes made on the external app. This will change (and might break) the task, even if it is currently in use by a course.</li><li><b>Create a new</b> task which copies the current state of this task. This will create a copy of this task on CodeHarbor, which will then be exported as a completely new exercise to the external app.</li></ul>'
task_found_no_right: 'A corresponding task has been found on external app, but you don''t have the rights to edit it. You can: <ul><li><b>Create a new</b> task which copies the current state of this task. This will create a copy of this task on CodeHarbor, which will then be exported as a completely new task to the external app.</li></ul>'
gpt_generate_tests:
invalid_api_key: The API key in your profile is invalid. Please add the appropriate API key in your profile to use the AI features.
invalid_description: The task description provided does not represent a valid programming task and therefore cannot be used to generate a unit test. Please make sure that the task description contains a clearly formulated problem that can be solved by a program.
no_language: Programming language is not specified for this task. Please specify the language before proceeding.
successful_generation: Unit test generated successfully. Please check the generated test and assign an appropriate filename.
1 change: 1 addition & 0 deletions config/locales/en/models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ en:
avatar:
not_an_image: needs to be an image
size_over_10_mb: size needs to be less than 10MB
invalid_api_key: The API key in your profile is invalid. Please add the appropriate API key in your profile to use the AI features.
models:
account_link:
one: Account link
Expand Down
1 change: 1 addition & 0 deletions config/locales/en/views/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ en:
add_to_collection_hint: Save Tasks for later by adding them to a collection.
button:
add_to_collection: Add to Collection
api_key_required: OpenAI API key is required to generate a test
create_collection: Create new Collection
download_as_zip: Download this Task as a ZIP file.
export: Export
Expand Down
4 changes: 4 additions & 0 deletions config/locales/en/views/users.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ en:
add_identity: Link %{kind} account
cannot_remove_last_identity: Cannot remove account link to %{kind} because neither a password is set nor another identity is linked
manage_omniauth: Manage linked accounts
openai_api_key_usage_html: Enter your OpenAI API key here to use various AI features within the platform. You can create an API key on the <a href='%{openai_api_link}' target='_blank' rel='noopener noreferrer'>OpenAI website</a>.
remove_identity: Remove account link to %{kind}
shared:
notification_modal:
Expand All @@ -51,6 +52,9 @@ en:
delete_modal:
title: Warning
full_name: Full name
openai_api_key: OpenAI API Key
openai_api_key_not_provided: Not entered
openai_api_key_provided: Entered
private_information: Private Information
public_information: Public Information
send_message: Send Message
1 change: 0 additions & 1 deletion config/settings/development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ omniauth:
oai_pmh:
admin_mail: [email protected]
open_ai:
access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys
model: gpt-3.5-turbo
1 change: 0 additions & 1 deletion config/settings/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,4 @@ omniauth:
oai_pmh:
admin_mail: [email protected]
open_ai:
access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys
model: gpt-3.5-turbo
1 change: 0 additions & 1 deletion config/settings/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,4 @@ omniauth:
oai_pmh:
admin_mail: [email protected]
open_ai:
access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys
model: gpt-3.5-turbo
7 changes: 7 additions & 0 deletions db/migrate/20240703221801_add_openai_api_key_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# frozen_string_literal: true

class AddOpenaiApiKeyToUsers < ActiveRecord::Migration[7.1]
def change
add_column :users, :openai_api_key, :string
end
end
3 changes: 2 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.1].define(version: 2024_05_31_161908) do
ActiveRecord::Schema[7.1].define(version: 2024_07_03_221801) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
Expand Down Expand Up @@ -327,6 +327,7 @@
t.string "preferred_locale"
t.boolean "password_set", default: true, null: false
t.integer "status_group", limit: 2, default: 0, null: false, comment: "Used as enum in Rails"
kkoehn marked this conversation as resolved.
Show resolved Hide resolved
t.string "openai_api_key"
t.index ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
Expand Down
13 changes: 5 additions & 8 deletions spec/controllers/tasks_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1043,17 +1043,14 @@
end

describe 'POST #generate_test' do
let(:task_user) { create(:user) }
let(:task_user) { create(:user, openai_api_key: 'valid_api_key') }
let(:access_level) { :public }
let(:task) { create(:task, user: task_user, access_level:) }
let(:mock_models) { instance_double(OpenAI::Models, list: {'data' => [{'id' => 'model-id'}]}) }

before do
allow(OpenAI::Client).to receive(:new).and_return(instance_double(OpenAI::Client, models: mock_models))
sign_in task_user
Settings.open_ai.access_token = 'access_token'
end

after do
Settings.open_ai.access_token = nil
end

context 'when GptGenerateTests is successful' do
Expand All @@ -1062,8 +1059,8 @@
post :generate_test, params: {id: task.id}
end

it 'calls the GptGenerateTests service with the correct task' do
expect(TaskService::GptGenerateTests).to have_received(:call).with(task:)
it 'calls the GptGenerateTests service with the correct parameters' do
expect(TaskService::GptGenerateTests).to have_received(:call).with(task:, openai_api_key: 'valid_api_key')
end

it 'redirects to the task show page' do
Expand Down
47 changes: 47 additions & 0 deletions spec/models/user_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,53 @@
expect(user.errors[:avatar]).to include(I18n.t('activerecord.errors.models.user.attributes.avatar.size_over_10_mb'))
end
end

context 'when openai_api_key is present and valid' do
before do
allow(OpenAI::Client).to receive(:new).and_return(instance_double(OpenAI::Client, models: mock_models))
user.openai_api_key = 'valid_key'
end

let(:mock_models) { instance_double(OpenAI::Models, list: {'data' => [{'id' => 'model-id'}]}) }

it 'is valid' do
expect(user).to be_valid
end
end

context 'when openai_api_key is present and invalid' do
before do
allow(OpenAI::Client).to receive(:new).and_return(instance_double(OpenAI::Client, models: mock_models))
allow(mock_models).to receive(:list).and_raise(OpenAI::Error)
user.openai_api_key = 'invalid_key'
user.valid?
end

let(:mock_models) { instance_double(OpenAI::Models) }

it 'is not valid' do
expect(user).not_to be_valid
end

it 'adds an error for invalid api key' do
expect(user.errors[:base]).to include(I18n.t('activerecord.errors.models.user.invalid_api_key'))
end
end

context 'when openai_api_key remains the same' do
before do
allow(OpenAI::Client).to receive(:new).and_return(instance_double(OpenAI::Client, models: mock_models))
user.openai_api_key = 'same_key'
user.save!
user.valid?
end

let(:mock_models) { instance_double(OpenAI::Models, list: {'data' => [{'id' => 'model-id'}]}) }

it 'does not trigger API validation' do
expect(user.errors[:base]).not_to include(I18n.t('activerecord.errors.models.user.invalid_api_key'))
end
end
end

describe '#destroy' do
Expand Down Expand Up @@ -166,7 +213,7 @@
create(:message, sender: user, param_type: 'collection', param_id: create(:collection).id)
end

it 'deletes message' do

Check warning on line 216 in spec/models/user_spec.rb

View workflow job for this annotation

GitHub Actions / test

User#destroy when user has sent messages when message has type exercise deletes message Failure/Error: expect { destroy }.to change(Message, :count).by(-3) expected `Message.count` to have changed by -3, but was changed by -1

Check warning on line 216 in spec/models/user_spec.rb

View workflow job for this annotation

GitHub Actions / test

User#destroy when user has sent messages when message has type exercise deletes message Failure/Error: expect { destroy }.to change(Message, :count).by(-3) expected `Message.count` to have changed by -3, but was changed by -1
expect { destroy }.to change(Message, :count).by(-3)
end
end
Expand Down
Loading
Loading