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
8 changes: 3 additions & 5 deletions app/controllers/tasks_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,10 @@ def export_external_confirm
# rubocop:enable Metrics/AbcSize

def generate_test
TaskService::GptGenerateTests.call(task: @task)
GptService::GenerateTests.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::MissingLanguageError
flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.no_language')
rescue Gpt::InvalidTaskDescription
flash[:alert] = I18n.t('tasks.task_service.gpt_generate_tests.invalid_description')
rescue Gpt::Error => e
flash[:alert] = e.localized_message
ensure
redirect_to @task
end
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
17 changes: 17 additions & 0 deletions app/errors/gpt/error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module Gpt
class Error < ApplicationError
class InternalServerError < Error; end

class InvalidApiKey < Error; end

class InvalidTaskDescription < Error; end

class MissingLanguage < Error; end

def localized_message
I18n.t("errors.gpt.#{self.class.name&.demodulize&.underscore}")
end
end
end
5 changes: 0 additions & 5 deletions app/errors/gpt/invalid_task_description.rb

This file was deleted.

5 changes: 0 additions & 5 deletions app/errors/gpt/missing_language_error.rb

This file was deleted.

9 changes: 9 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 @@ -148,6 +149,14 @@ def to_s

private

def validate_openai_api_key
return unless openai_api_key_changed?

GptService::ValidateApiKey.call(openai_api_key:)
rescue Gpt::Error::InvalidApiKey
errors.add(:base, :invalid_api_key)
end

def avatar_format
avatar_blob = avatar.blob
if avatar_blob.content_type.start_with? 'image/'
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
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# frozen_string_literal: true

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

@task = task
@client = new_client! openai_api_key
end

def execute
Expand All @@ -20,35 +21,33 @@ def execute

private

def client
@client ||= OpenAI::Client.new
end

def gpt_response
# train client with some prompts
messages = training_prompts.map do |prompt|
{role: 'system', content: prompt}
end
wrap_api_error! do
# train client with some prompts
messages = training_prompts.map do |prompt|
{role: 'system', content: prompt}
end

# send user message
messages << {role: 'user', content: @task.description}
# send user message
messages << {role: 'user', content: @task.description}

# create gpt client
response = client.chat(
parameters: {
model: Settings.open_ai.model,
messages:,
temperature: 0.7, # Lower values insure reproducibility
}
)
# create gpt client
response = @client.chat(
parameters: {
model: Settings.open_ai.model,
messages:,
temperature: 0.7, # Lower values insure reproducibility
}
)

# parse out the response
raw_response = response.dig('choices', 0, 'message', 'content')
# parse out the response
raw_response = response.dig('choices', 0, 'message', 'content')

# check for ``` in the response and extract the text between the first set
raise Gpt::InvalidTaskDescription unless raw_response.include?('```')
# check for ``` in the response and extract the text between the first set
raise Gpt::Error::InvalidTaskDescription unless raw_response.include?('```')

raw_response[/```(.*?)```/m, 1].lines[1..]&.join&.strip
raw_response[/```(.*?)```/m, 1].lines[1..]&.join&.strip
end
end

def training_prompts
Expand Down
23 changes: 23 additions & 0 deletions app/services/gpt_service/gpt_service_base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

module GptService
class GptServiceBase < ServiceBase
def new_client!(access_token)
raise Gpt::Error::InvalidApiKey if access_token.blank?

OpenAI::Client.new(access_token:)
end

private

def wrap_api_error!
yield
rescue Faraday::UnauthorizedError, OpenAI::Error => e
raise Gpt::Error::InvalidApiKey.new("Could not authenticate with OpenAI: #{e.message}")
rescue Faraday::Error => e
raise Gpt::Error::InternalServerError.new("Could not communicate with OpenAI: #{e.inspect}")
rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, SocketError, EOFError => e
raise Gpt::Error.new(e)
end
end
end
22 changes: 22 additions & 0 deletions app/services/gpt_service/validate_api_key.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module GptService
class ValidateApiKey < GptServiceBase
def initialize(openai_api_key:)
super()

@client = new_client! openai_api_key
end

def execute
validate!
end

def validate!
wrap_api_error! do
response = @client.models.list
raise Gpt::Error::InvalidApiKey unless response['data']
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.

2 changes: 0 additions & 2 deletions config/locales/de/controllers/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,4 @@ 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_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.
9 changes: 9 additions & 0 deletions config/locales/de/errors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
de:
errors:
gpt:
error: Beim Generieren des Unit-Tests ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.
internal_server_error: Der OpenAI-Server ist derzeit nicht verfügbar. Bitte versuchen Sie es später erneut, wenn das Problem behoben wurde.
invalid_api_key: Der API-Schlüssel in Ihrem Profil ist ungültig oder abgelaufen. Bitte aktualisieren Sie den API-Schlüssel in Ihrem Profil um die KI-Funktionen zu nutzen und versuchen Sie es anschließend erneut.
invalid_task_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.
missing_language: Für diese Aufgabe ist keine Programmiersprache angegeben. Bitte geben Sie die Sprache an, bevor Sie fortfahren.
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
2 changes: 0 additions & 2 deletions config/locales/en/controllers/tasks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,4 @@ 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_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.
9 changes: 9 additions & 0 deletions config/locales/en/errors.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
en:
errors:
gpt:
error: An error occurred while generating the unit test. Please try again later.
internal_server_error: The OpenAI server is currently experiencing an outage. Please try again later when the issue is fixed.
invalid_api_key: The API key in your profile is invalid or has expired. Please update the API key in your profile to use the AI features and try again.
invalid_task_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.
missing_language: Programming language is not specified for this task. Please specify the language before proceeding.
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-4o-mini
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-4o-mini
1 change: 0 additions & 1 deletion config/settings/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,4 @@ nbp:
name: CodeHarbor
slug: CoHaP2
open_ai:
access_token: ~ # Add a valid API key from https://platform.openai.com/api-keys
model: gpt-4o-mini
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
Loading
Loading