From 4ba3f0801ad8cfbc5849f983e7df161853120b59 Mon Sep 17 00:00:00 2001 From: henrrypg Date: Mon, 20 Oct 2025 14:12:39 -0500 Subject: [PATCH 1/5] feat: use tutor configs as django settings --- backend/openedx_ai_extensions/settings/common.py | 3 +-- backend/openedx_ai_extensions/workflows/models.py | 8 ++++---- .../patches/openedx-common-settings | 5 +++++ tutor/openedx_ai_extensions/plugin.py | 11 +++++++++++ 4 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 tutor/openedx_ai_extensions/patches/openedx-common-settings diff --git a/backend/openedx_ai_extensions/settings/common.py b/backend/openedx_ai_extensions/settings/common.py index 189785a..56d5192 100644 --- a/backend/openedx_ai_extensions/settings/common.py +++ b/backend/openedx_ai_extensions/settings/common.py @@ -10,5 +10,4 @@ def plugin_settings(settings): # pylint: disable=unused-argument Args: settings (dict): Django settings object """ - settings.AI_MODEL = 'gpt-4.1-mini' - settings.OPENAI_API_KEY = "make_it_read_from_tutor" + pass diff --git a/backend/openedx_ai_extensions/workflows/models.py b/backend/openedx_ai_extensions/workflows/models.py index aa81066..9f0ed41 100644 --- a/backend/openedx_ai_extensions/workflows/models.py +++ b/backend/openedx_ai_extensions/workflows/models.py @@ -79,11 +79,11 @@ def get_config(cls, action: str, course_id: Optional[str] = None): "char_limit": 300, }, 'LLMProcessor': { - 'api_key': settings.OPENAI_API_KEY, - 'model': settings.AI_MODEL, - 'temperature': 0.7, + 'api_key': settings.OPENEDX_AI_EXTENSIONS_API_KEY, + 'model': settings.OPENEDX_AI_EXTENSIONS_MODEL, + 'temperature': settings.OPENEDX_AI_EXTENSIONS_TEMPERATURE, # 'function': "summarize_content", - 'function': "explain_like_five", + 'function': settings.OPENEDX_AI_EXTENSIONS_LLM_FUNCTION, }, }, actuator_config={}, # TODO: first I must make the actuator selection dynamic diff --git a/tutor/openedx_ai_extensions/patches/openedx-common-settings b/tutor/openedx_ai_extensions/patches/openedx-common-settings new file mode 100644 index 0000000..d4ca8a4 --- /dev/null +++ b/tutor/openedx_ai_extensions/patches/openedx-common-settings @@ -0,0 +1,5 @@ +OPENEDX_AI_EXTENSIONS_VERSION = "{{ OPENEDX_AI_EXTENSIONS_VERSION }}" +OPENEDX_AI_EXTENSIONS_API_KEY = "{{ OPENEDX_AI_EXTENSIONS_API_KEY }}" +OPENEDX_AI_EXTENSIONS_MODEL = "{{ OPENEDX_AI_EXTENSIONS_MODEL }}" +OPENEDX_AI_EXTENSIONS_TEMPERATURE = {{ OPENEDX_AI_EXTENSIONS_TEMPERATURE }} +OPENEDX_AI_EXTENSIONS_LLM_FUNCTION = "{{ OPENEDX_AI_EXTENSIONS_LLM_FUNCTION }}" diff --git a/tutor/openedx_ai_extensions/plugin.py b/tutor/openedx_ai_extensions/plugin.py index a815644..7d2eda2 100644 --- a/tutor/openedx_ai_extensions/plugin.py +++ b/tutor/openedx_ai_extensions/plugin.py @@ -8,6 +8,17 @@ from .__about__ import __version__ +hooks.Filters.CONFIG_DEFAULTS.add_items( + [ + # Add your new settings that have default values here. + # Each new setting is a pair: (setting_name, default_value). + ("OPENEDX_AI_EXTENSIONS_VERSION", __version__), + ("OPENEDX_AI_EXTENSIONS_API_KEY", None), + ("OPENEDX_AI_EXTENSIONS_MODEL", "gpt-5-mini"), + ("OPENEDX_AI_EXTENSIONS_TEMPERATURE", 0.7), + ("OPENEDX_AI_EXTENSIONS_LLM_FUNCTION", "explain_like_five"), + ] +) ######################## # Plugin path management From b7fd7105004a6414af520f9c5db76227df203bcc Mon Sep 17 00:00:00 2001 From: henrrypg Date: Thu, 23 Oct 2025 11:19:23 -0500 Subject: [PATCH 2/5] feat: better config --- backend/openedx_ai_extensions/settings/common.py | 10 +++++++++- backend/openedx_ai_extensions/workflows/models.py | 9 ++++----- .../patches/openedx-common-settings | 6 +----- tutor/openedx_ai_extensions/plugin.py | 12 +++++++----- 4 files changed, 21 insertions(+), 16 deletions(-) diff --git a/backend/openedx_ai_extensions/settings/common.py b/backend/openedx_ai_extensions/settings/common.py index 56d5192..5906da6 100644 --- a/backend/openedx_ai_extensions/settings/common.py +++ b/backend/openedx_ai_extensions/settings/common.py @@ -1,6 +1,7 @@ """ Common settings for the openedx_ai_extensions application. """ +import os def plugin_settings(settings): # pylint: disable=unused-argument @@ -10,4 +11,11 @@ def plugin_settings(settings): # pylint: disable=unused-argument Args: settings (dict): Django settings object """ - pass + if not hasattr(settings, "OPENEDX_AI_EXTENSIONS"): + settings.OPENEDX_AI_EXTENSIONS = os.getenv("OPENEDX_AI_EXTENSIONS", default={ + "default": { + "API_KEY": "", + "LITELLM_MODEL": "gpt-5-mini", + "TEMPERATURE": 1, + } + }) diff --git a/backend/openedx_ai_extensions/workflows/models.py b/backend/openedx_ai_extensions/workflows/models.py index 9f0ed41..458adff 100644 --- a/backend/openedx_ai_extensions/workflows/models.py +++ b/backend/openedx_ai_extensions/workflows/models.py @@ -79,11 +79,10 @@ def get_config(cls, action: str, course_id: Optional[str] = None): "char_limit": 300, }, 'LLMProcessor': { - 'api_key': settings.OPENEDX_AI_EXTENSIONS_API_KEY, - 'model': settings.OPENEDX_AI_EXTENSIONS_MODEL, - 'temperature': settings.OPENEDX_AI_EXTENSIONS_TEMPERATURE, - # 'function': "summarize_content", - 'function': settings.OPENEDX_AI_EXTENSIONS_LLM_FUNCTION, + 'api_key': settings.OPENEDX_AI_EXTENSIONS['default']['API_KEY'], + 'model': settings.OPENEDX_AI_EXTENSIONS['default']['LITELLM_MODEL'], + 'temperature': settings.OPENEDX_AI_EXTENSIONS['default']['TEMPERATURE'], + 'function': "summarize_content", }, }, actuator_config={}, # TODO: first I must make the actuator selection dynamic diff --git a/tutor/openedx_ai_extensions/patches/openedx-common-settings b/tutor/openedx_ai_extensions/patches/openedx-common-settings index d4ca8a4..29b718c 100644 --- a/tutor/openedx_ai_extensions/patches/openedx-common-settings +++ b/tutor/openedx_ai_extensions/patches/openedx-common-settings @@ -1,5 +1 @@ -OPENEDX_AI_EXTENSIONS_VERSION = "{{ OPENEDX_AI_EXTENSIONS_VERSION }}" -OPENEDX_AI_EXTENSIONS_API_KEY = "{{ OPENEDX_AI_EXTENSIONS_API_KEY }}" -OPENEDX_AI_EXTENSIONS_MODEL = "{{ OPENEDX_AI_EXTENSIONS_MODEL }}" -OPENEDX_AI_EXTENSIONS_TEMPERATURE = {{ OPENEDX_AI_EXTENSIONS_TEMPERATURE }} -OPENEDX_AI_EXTENSIONS_LLM_FUNCTION = "{{ OPENEDX_AI_EXTENSIONS_LLM_FUNCTION }}" +OPENEDX_AI_EXTENSIONS = {{ OPENEDX_AI_EXTENSIONS }} diff --git a/tutor/openedx_ai_extensions/plugin.py b/tutor/openedx_ai_extensions/plugin.py index 7d2eda2..0262fc2 100644 --- a/tutor/openedx_ai_extensions/plugin.py +++ b/tutor/openedx_ai_extensions/plugin.py @@ -12,11 +12,13 @@ [ # Add your new settings that have default values here. # Each new setting is a pair: (setting_name, default_value). - ("OPENEDX_AI_EXTENSIONS_VERSION", __version__), - ("OPENEDX_AI_EXTENSIONS_API_KEY", None), - ("OPENEDX_AI_EXTENSIONS_MODEL", "gpt-5-mini"), - ("OPENEDX_AI_EXTENSIONS_TEMPERATURE", 0.7), - ("OPENEDX_AI_EXTENSIONS_LLM_FUNCTION", "explain_like_five"), + ("OPENEDX_AI_EXTENSIONS", [{ + "default": { + "API_KEY": "", + "LITELLM_MODEL": "gpt-5-mini", + "TEMPERATURE": 1, + } + }]), ] ) From ecdda37a9cd715c96ac41d72c0ad9d363bcb1573 Mon Sep 17 00:00:00 2001 From: henrrypg Date: Mon, 27 Oct 2025 08:57:08 -0500 Subject: [PATCH 3/5] feat: add settings defaults --- .../openedx_ai_extensions/settings/common.py | 28 +++++++++++++------ tutor/openedx_ai_extensions/plugin.py | 6 ++-- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/backend/openedx_ai_extensions/settings/common.py b/backend/openedx_ai_extensions/settings/common.py index 5906da6..1db55da 100644 --- a/backend/openedx_ai_extensions/settings/common.py +++ b/backend/openedx_ai_extensions/settings/common.py @@ -2,6 +2,10 @@ Common settings for the openedx_ai_extensions application. """ import os +import logging +from copy import deepcopy + +logger = logging.getLogger(__name__) def plugin_settings(settings): # pylint: disable=unused-argument @@ -11,11 +15,19 @@ def plugin_settings(settings): # pylint: disable=unused-argument Args: settings (dict): Django settings object """ - if not hasattr(settings, "OPENEDX_AI_EXTENSIONS"): - settings.OPENEDX_AI_EXTENSIONS = os.getenv("OPENEDX_AI_EXTENSIONS", default={ - "default": { - "API_KEY": "", - "LITELLM_MODEL": "gpt-5-mini", - "TEMPERATURE": 1, - } - }) + CONFIG_DEFAULTS = { + "default": { + "API_KEY": "put_your_api_key_here", + "LITELLM_MODEL": "gpt-5-mini", + "TEMPERATURE": 1, + } + } + config = deepcopy(CONFIG_DEFAULTS) + if hasattr(settings, "OPENEDX_AI_EXTENSIONS"): + for section, values in settings.OPENEDX_AI_EXTENSIONS.items(): + if section in config: + logger.warning(f"OpenedX AI Extensions settings: {settings.OPENEDX_AI_EXTENSIONS}") + config[section].update(values) + else: + config[section] = values + settings.OPENEDX_AI_EXTENSIONS = config diff --git a/tutor/openedx_ai_extensions/plugin.py b/tutor/openedx_ai_extensions/plugin.py index 0262fc2..b4da248 100644 --- a/tutor/openedx_ai_extensions/plugin.py +++ b/tutor/openedx_ai_extensions/plugin.py @@ -4,9 +4,7 @@ import importlib_resources from tutor import hooks -from tutormfe.hooks import MFE_APPS, PLUGIN_SLOTS - -from .__about__ import __version__ +from tutormfe.hooks import PLUGIN_SLOTS hooks.Filters.CONFIG_DEFAULTS.add_items( [ @@ -14,7 +12,7 @@ # Each new setting is a pair: (setting_name, default_value). ("OPENEDX_AI_EXTENSIONS", [{ "default": { - "API_KEY": "", + "API_KEY": "put_your_api_key_here", "LITELLM_MODEL": "gpt-5-mini", "TEMPERATURE": 1, } From a00ac4d4e0bb33eaca84edcb47831f985292ea76 Mon Sep 17 00:00:00 2001 From: Felipe Montoya Date: Fri, 31 Oct 2025 19:22:16 -0500 Subject: [PATCH 4/5] feat: formatting the LLM providers settings as profiles. --- adding simple configuration with key only for openai and anthropic and override capacity --- .../processors/llm_processor.py | 14 +++++++++---- .../openedx_ai_extensions/settings/common.py | 18 ---------------- .../settings/production.py | 19 +++++++++++++++++ .../openedx_ai_extensions/workflows/models.py | 7 ++----- .../patches/openedx-auth | 21 +++++++++++++++++++ .../patches/openedx-common-settings | 1 - tutor/openedx_ai_extensions/plugin.py | 13 ------------ 7 files changed, 52 insertions(+), 41 deletions(-) create mode 100644 tutor/openedx_ai_extensions/patches/openedx-auth delete mode 100644 tutor/openedx_ai_extensions/patches/openedx-common-settings diff --git a/backend/openedx_ai_extensions/processors/llm_processor.py b/backend/openedx_ai_extensions/processors/llm_processor.py index fe6bf1f..2504790 100644 --- a/backend/openedx_ai_extensions/processors/llm_processor.py +++ b/backend/openedx_ai_extensions/processors/llm_processor.py @@ -4,6 +4,7 @@ import logging +from django.conf import settings from litellm import completion logger = logging.getLogger(__name__) @@ -18,11 +19,14 @@ def __init__(self, config=None): class_name = self.__class__.__name__ self.config = config.get(class_name, {}) + self.config_profile = self.config.get("config", "default") + # Extract API configuration once during initialization - self.api_key = self.config.get("api_key") - self.model = self.config.get("model") - self.temperature = self.config.get("temperature") # No default - self.max_tokens = self.config.get("max_tokens") # No default + self.api_key = settings.AI_EXTENSIONS[self.config_profile]['API_KEY'] + self.model = settings.AI_EXTENSIONS[self.config_profile]['LITELLM_MODEL'] + self.timeout = settings.AI_EXTENSIONS[self.config_profile]['TIMEOUT'] + self.temperature = settings.AI_EXTENSIONS[self.config_profile]['TEMPERATURE'] + self.max_tokens = settings.AI_EXTENSIONS[self.config_profile]['MAX_TOKENS'] if not self.api_key: logger.error("AI API key not configured") @@ -57,6 +61,8 @@ def _call_completion_api(self, system_role, user_content): completion_params["temperature"] = self.temperature if self.max_tokens is not None: completion_params["max_tokens"] = self.max_tokens + if self.timeout is not None: + completion_params["timeout"] = self.timeout response = completion(**completion_params) content = response.choices[0].message.content diff --git a/backend/openedx_ai_extensions/settings/common.py b/backend/openedx_ai_extensions/settings/common.py index 1db55da..d1854b5 100644 --- a/backend/openedx_ai_extensions/settings/common.py +++ b/backend/openedx_ai_extensions/settings/common.py @@ -1,9 +1,7 @@ """ Common settings for the openedx_ai_extensions application. """ -import os import logging -from copy import deepcopy logger = logging.getLogger(__name__) @@ -15,19 +13,3 @@ def plugin_settings(settings): # pylint: disable=unused-argument Args: settings (dict): Django settings object """ - CONFIG_DEFAULTS = { - "default": { - "API_KEY": "put_your_api_key_here", - "LITELLM_MODEL": "gpt-5-mini", - "TEMPERATURE": 1, - } - } - config = deepcopy(CONFIG_DEFAULTS) - if hasattr(settings, "OPENEDX_AI_EXTENSIONS"): - for section, values in settings.OPENEDX_AI_EXTENSIONS.items(): - if section in config: - logger.warning(f"OpenedX AI Extensions settings: {settings.OPENEDX_AI_EXTENSIONS}") - config[section].update(values) - else: - config[section] = values - settings.OPENEDX_AI_EXTENSIONS = config diff --git a/backend/openedx_ai_extensions/settings/production.py b/backend/openedx_ai_extensions/settings/production.py index 2b891ca..41bbfe7 100644 --- a/backend/openedx_ai_extensions/settings/production.py +++ b/backend/openedx_ai_extensions/settings/production.py @@ -14,3 +14,22 @@ def plugin_settings(settings): """ # Apply common settings common_settings(settings) + LITELLM_BASE = { + "TIMEOUT": 600, # Request timeout in seconds + "MAX_TOKENS": 4096, # Max tokens per request + "TEMPERATURE": 0.7, # Response randomness (0-1) + } + + if hasattr(settings, "AI_EXTENSIONS"): + first_key = next(iter(settings.AI_EXTENSIONS)) + + # Merge base config into all profiles + merged_extensions = {} + for key, config in settings.AI_EXTENSIONS.items(): + merged_extensions[key] = {**LITELLM_BASE, **config} + + # Make first profile also default + settings.AI_EXTENSIONS = { + "default": {**LITELLM_BASE, **settings.AI_EXTENSIONS[first_key]}, + **merged_extensions + } diff --git a/backend/openedx_ai_extensions/workflows/models.py b/backend/openedx_ai_extensions/workflows/models.py index 458adff..5688065 100644 --- a/backend/openedx_ai_extensions/workflows/models.py +++ b/backend/openedx_ai_extensions/workflows/models.py @@ -5,7 +5,6 @@ import logging from typing import Any, Dict, Optional -from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.db import models @@ -79,10 +78,8 @@ def get_config(cls, action: str, course_id: Optional[str] = None): "char_limit": 300, }, 'LLMProcessor': { - 'api_key': settings.OPENEDX_AI_EXTENSIONS['default']['API_KEY'], - 'model': settings.OPENEDX_AI_EXTENSIONS['default']['LITELLM_MODEL'], - 'temperature': settings.OPENEDX_AI_EXTENSIONS['default']['TEMPERATURE'], - 'function': "summarize_content", + 'function': "explain_like_five", + 'config': "default", }, }, actuator_config={}, # TODO: first I must make the actuator selection dynamic diff --git a/tutor/openedx_ai_extensions/patches/openedx-auth b/tutor/openedx_ai_extensions/patches/openedx-auth new file mode 100644 index 0000000..2116bbb --- /dev/null +++ b/tutor/openedx_ai_extensions/patches/openedx-auth @@ -0,0 +1,21 @@ +{%- if AI_EXTENSIONS is defined %} +AI_EXTENSIONS: +{%- for profile_key, profile_config in AI_EXTENSIONS.items() %} + {{ profile_key }}: + {%- for key, value in profile_config.items() %} + {{ key }}: "{{ value }}" + {%- endfor %} +{%- endfor %} +{%- elif AI_EXTENSIONS_OPENAI_API_KEY is defined or AI_EXTENSIONS_ANTHROPIC_API_KEY is defined %} +AI_EXTENSIONS: + {%- if AI_EXTENSIONS_OPENAI_API_KEY is defined %} + openai: + API_KEY: "{{ AI_EXTENSIONS_OPENAI_API_KEY }}" + LITELLM_MODEL: "{{ AI_EXTENSIONS_OPENAI_MODEL | default("gpt-4.1-mini") }}" + {%- endif %} + {%- if AI_EXTENSIONS_ANTHROPIC_API_KEY is defined %} + anthropic: + API_KEY: "{{ AI_EXTENSIONS_ANTHROPIC_API_KEY }}" + LITELLM_MODEL: "{{ AI_EXTENSIONS_ANTHROPIC_MODEL | default("claude-3-haiku-20240307") }}" + {%- endif %} +{% endif %} diff --git a/tutor/openedx_ai_extensions/patches/openedx-common-settings b/tutor/openedx_ai_extensions/patches/openedx-common-settings deleted file mode 100644 index 29b718c..0000000 --- a/tutor/openedx_ai_extensions/patches/openedx-common-settings +++ /dev/null @@ -1 +0,0 @@ -OPENEDX_AI_EXTENSIONS = {{ OPENEDX_AI_EXTENSIONS }} diff --git a/tutor/openedx_ai_extensions/plugin.py b/tutor/openedx_ai_extensions/plugin.py index b4da248..3d18ad5 100644 --- a/tutor/openedx_ai_extensions/plugin.py +++ b/tutor/openedx_ai_extensions/plugin.py @@ -6,19 +6,6 @@ from tutor import hooks from tutormfe.hooks import PLUGIN_SLOTS -hooks.Filters.CONFIG_DEFAULTS.add_items( - [ - # Add your new settings that have default values here. - # Each new setting is a pair: (setting_name, default_value). - ("OPENEDX_AI_EXTENSIONS", [{ - "default": { - "API_KEY": "put_your_api_key_here", - "LITELLM_MODEL": "gpt-5-mini", - "TEMPERATURE": 1, - } - }]), - ] -) ######################## # Plugin path management From d82be77d881eb80a1502dda4f08f520a32928496 Mon Sep 17 00:00:00 2001 From: henrrypg Date: Tue, 4 Nov 2025 09:18:39 -0500 Subject: [PATCH 5/5] feat: add some api tests --- backend/tests/__init__.py | 0 backend/tests/test_api.py | 66 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 backend/tests/__init__.py diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index b685ad8..bf7f854 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -58,3 +58,69 @@ def test_api_urls_are_registered(): # Test that the v1 workflows URL can be reversed url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") assert url == "/openedx-ai-extensions/v1/workflows/" + + +@pytest.mark.django_db +def test_workflows_endpoint_requires_authentication(api_client): # pylint: disable=redefined-outer-name + """ + Test that the workflows endpoint requires authentication. + """ + url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + + # Test POST without authentication + response = api_client.post(url, {}, format="json") + assert response.status_code == 302 # Redirect to login + + # Test GET without authentication + response = api_client.get(url) + assert response.status_code == 302 # Redirect to login + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user") +def test_workflows_post_with_authentication(api_client, course_key): # pylint: disable=redefined-outer-name + """ + Test POST request to workflows endpoint with authentication. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + + payload = { + "action": "summarize", + "courseId": str(course_key), + "context": {"unitId": "unit-123"}, + "user_input": {"text": "Explain quantum physics"}, + "requestId": "test-request-123", + } + + response = api_client.post(url, payload, format="json") + + # Should return 200 or 500 depending on workflow execution + assert response.status_code in [200, 400, 500] + + # Response should be JSON + assert response["Content-Type"] == "application/json" + + # Check for expected fields in response + data = response.json() + assert "requestId" in data + assert "timestamp" in data + assert "workflow_created" in data + + +@pytest.mark.django_db +@pytest.mark.usefixtures("user", "course_key") +def test_workflows_get_with_authentication(api_client): # pylint: disable=redefined-outer-name + """ + Test GET request to workflows endpoint with authentication. + """ + api_client.login(username="testuser", password="password123") + url = reverse("openedx_ai_extensions:api:v1:ai_pipelines") + + response = api_client.get(url) + + # Should return 200 or error status + assert response.status_code in [200, 400, 500] + + # Response should be JSON + assert response["Content-Type"] == "application/json"