Thanks Dave for such thoughtful responses. This has been exactly the conversation I was hoping to have in order to reach a well tough out solution.
Let me go through your assumptions and then get to your concrete proposal at the end.
Assumption 1: Implementing as a runtime service does not give us more flexibility when it comes to version conflict management. Python packages are already free to declare optional, extra dependencies.
They can also declare acceptable version ranges for those dependencies, and it would be the job of pip (and later uv) to figure out the real version to pin to. If there are irreconcilable versioning conflicts, then that’s a real problem that’s better to figure out at build time than run time.
- build-time selection of extras isn’t actually easy on the supported build path.
pip install ora2[extra] works interactively, but in a Tutor image build you can’t flip on an extra for a dependency that openedx-platform already pins in its base requirements without forking it.
- for xblocks the goal is to not carry the dependency at all. The xblock declares wants(“ai_extensions”), ships zero AI dependency, and binds only when the platform has the framework. That’s what
completion does in an example below.
I do appreciate that pip/uv do resolve version ranges better than a runtime mechanism. Going over runtime services puts all the pressure on the interface created by the framework and it means we need to make it a lot more tight. Leaning into all the best practices set by oep-49. Maybe even offering a versioned API and a lot of documentation.
I think it would be good to look at what we do for other transversal dependencies such as grades/submissions/completion.
edx-sga uses:
from submissions import api as submissions_api
from submissions.models import StudentItem as SubmissionsStudent
from submissions.models import Submission
taken from edx-sga/edx_sga/sga.py at df94598d90560ef04f63722475c388d7f3b3f898 · mitodl/edx-sga · GitHub
xblock-flow-control extracts an lms code call in a runtime imported module with:
from flow_control.edxapp_wrapper.score import (
score_module as ScoresClient,
)
# ---
from importlib import import_module
from django.conf import settings
def get_score_module_function(*args, **kwargs):
"""Get ScoreModule model."""
backend_function = settings.FLOW_CONTROL_SCORE_MODULE_BACKEND
backend = import_module(backend_function)
return backend.get_score_module(*args, **kwargs)
score_module = get_score_module_function
# ---
from lms.djangoapps.courseware.model_data import ScoresClient
taken from flow-control-xblock/flow_control/edxapp_wrapper/backends/score_s_v1.py at bf27bf142397b63e675b914f8ff9691067ad50f9 · eduNEXT/flow-control-xblock · GitHub
Completion on the other hand is offered as a service:
completion_service = self.runtime.service(self, 'completion')
taken from openedx-platform/lms/djangoapps/lms_xblock/mixin.py at master · openedx/openedx-platform · GitHub
In recap:
- sga → hard import of submissions (tight coupling),
- flow-control → settings-swappable edxapp_wrapper backend (the hoops we devs jump through to avoid a hard import),
- completion → runtime service (the clean one).
Assumption 2: Even if the runtime service is installed, we still have to deal with the “it’s not available” case.
Maybe it’s installed, but it’s been turned off by configuration at the site level. Or maybe there is no LLM configured. Or perhaps an LLM is configured, but is not available to the current user for whatever reason
Agree: we will have to handle happy and sad paths regardless of how the LLM call is being connected to the xblock code. The way I see it there are two separate layers. Whether the service is installed at all is a cheap binary check (wants + the service being None).
Whether AI is actually usable right now is answered by the API at call time and the xblock handles it the same way whether it reached that API by import or by service. There are a myriad of configurations that could cause an invalid response, but also operational issues with models, API KEY limitations, outages, models are removed constantly and much more. A good portion of that is what the ai-extensions-framework tries to solve. That’s why we are all in agreement that we prefer having xblocks reusing the implementation that ai-extensions offers rather than directly handling the llm calls.
Currently, there is no big switch to turn everything on or off. It is managed by the existence of DB stored scopes and profiles. This is a concern we could resolve by having a config/waffle switch that makes the runtime service return None which means it’s not available even if the code is there. Optionally it could return an api object that lets the xblock tell that ai services are turned off rather than uninstalled (I think this is a stretch, but for some consumers it might make sense).
This will be a lot of responsibilities for caller xblocks and it will build on top of the API design.
Which is a tangent, but also worth touching. What I’m expecting even if we go through a service or directly require the code, is that the call to the ai-extensions over the python API will go something like this (exposed through the api.py as well):
from openedx_ai_extensions import api as ai_service # in case we go over direct import
ai_service = self.runtime.service(self, 'ai_extensions') # in case we go over runtime services
ai_service.public_function(
user_input=data,
context_data=data,
)
The implementation of the function should be able to calculate the scope and thus find a suitable matching profile in the DB. The problem with this is that it requires admins to go define/configure profiles for every xblock they want to use.
We can alternatively support a profile argument to let the developers handle the config part directly, but this risks not respecting the administrator configurations to turn something on and off.
Also we could expose a way for xblocks (or api consumers) to save their profiles to the DB and then let admins take over and further control the definitions of the AI workloads.
This entire assumption further strengthens the case for a stable api object.
Assumption 3: The installation overhead of libraries for LLM communication is small. LLMs can be insanely expensive to run, and the communication process requires async tasks and possibly other operational complexity. But the actual overhead of the library code to manage that is small.
Not entirely. Agreed that an installed-but-unused library costs almost nothing at runtime. However, the cost is the dependency surface you take on platform-wide the moment this is part of the required install for everyone: disk, native wheels, package count, and above all the release cadence and version conflicts.
When we started working there were 2 projects that we knew that served as llm routers (litellm and langchain). After some evaluation we picked litellm and moved on. Later as time went on it turned out it is deceptively large as also langchain is.
Some comparison numbers current as of today. (Numbers below come from https://claude.ai/public/artifacts/1328550d-6008-4c20-820a-861a8c17b124)
|
LiteLLM (base) |
any-llm-sdk [openai,anthropic] |
any-llm-sdk [all] |
aisuite (base) |
mirascope |
instructor |
| Packages added |
56 |
26 |
136 |
12 |
16 |
41 |
| Disk (site-packages) |
210 MB |
77 MB |
521 MB |
23 MB |
36 MB |
83 MB |
| Own source lines |
284,814 |
17,148 |
17,148 |
6,560 |
34,708 |
26,001 |
| Own Python files |
1,939 |
142 |
142 |
40 |
173 |
195 |
| Mandatory Rust binaries |
tiktoken, tokenizers, pydantic_core, regex, rpds-py, jiter, hf-xet |
pydantic_core, jiter |
pydantic_core, jiter (+ provider-specific) |
none (base) |
pydantic_core, jiter |
pydantic_core, jiter |
| Releases (12 months) |
189 |
70 |
70 |
3 |
24 |
17 |
| Simultaneous active minor series |
6 |
1 |
1 |
1 |
1 |
1 |
| Has official CHANGELOG |
 |
(GitHub releases) |
 |
 |
 |
 |
| openai v1 compatible |
(requires v2) |
(floor at 1.99.3) |
 |
 |
N/A |
 |
| Python 3.10 support |
 |
(3.11+ only) |
 |
 |
 |
 |
| Forces huggingface_hub |
yes (mandatory) |
no |
no |
no |
no |
no |
| Forces python-dotenv |
yes (mandatory) |
no |
no |
no |
no |
no |
| Provider count |
100+ |
2 explicit + passthrough |
42 |
15+ |
20+ |
15+ |
| Fallback / load balancing |
 |
 |
 |
 |
 |
 |
Even the smallest library adds a few MBs, but litellm is a lot bigger. We would not want the whole jungle imported to run the unit tests in the gorila package.
If we ever decide to reduce the weight, we could switch to any-llm which is also well supported and select carefully which providers get installed by default, but that is a big refactor.
The churn of versions is even worse. Some of those routers and litellm in particular are designed for fast updaters which our 2 releases per year will struggle with. Not something that I was carefully consider in the ai-extensions release cycle, but I’ll be on top of that from now on.
Assumption 4: We are not seriously entertaining the notion of competing AI frameworks for Open edX Platform integration, only competing backend processors/adapters.
We’re making a bet on our AI Extensibility Framework, and we’re not seriously thinking that the interface APIs are going to be re-implemented by competing frameworks.
I mostly agree there won’t be competing frameworks. But I don’t think that’s where the abstraction earns its place. The value I’m after is the dependency boundary from Assumptions 1–2, the xblock targets a service name and carries no extra weight, and that holds even if ai-extensions is the only implementation that ever exists.
Though I wouldn’t rule competing implementations out either (they sort of already exist). MIT runs ask-tim on asides with their own backend, and WGU/ASU built ai-coach on litellm. A common service name (with the overrides group in the PR) is exactly what lets one xblock target “the AI service” and have either of those satisfy it.
Assumption 5: Many things that are not XBlocks are going to want to use this framework.
Yes, and I think this is the clearest way to see the whole thing: there should be only one public api.py, and consumers reach it the way that fits them.
Forums and other consumers will also have to deal with:
- what happens if not available/turned off
- how does the framework respond to ops issues (outages, model errors, incorrect output, …)
- do we use profiles/scopes defined at the DB level?
- how do operators choose their models/providers/keys
- anything we devise for auditing mechanisms and human in the loop
- supervision loops for the safety of llm responses
Why is the AI Extensibility Framework a Plugin?
The ADR answer was very on point when we started. We wanted to experiment and explore the ways in which LLM capabilities could be leveraged for the platform. Having this feature as a plugin made it easy to build rapidly for the nearly one year we have been working on it. I would however add:
- because it allows adopters to have a faster cadence of upgrading the ai-extensions-framework without having to wait for the new release and also run a long migration. Some adopters are still running teak which is very well supported.
Finally I get to your concrete questions
- We stop considering the AI Extensibility Framework itself a plugin, and have openedx-platform declare it as a normal dependency (like we do for openedx-core). It would have no LLM backends enabled by default.
I think this could work, but there are nuances to consider.
First, ai-extensions today is more than a backend: it’s a django backend, a react frontend and a tutor plugin that wires it all together. Making it a normal platform dependency really means the backend becomes a dependency; the frontend still has to load as an MFE/slot, so that part stays plugin-shaped no matter what.
Second, I wouldn’t make the whole backend a hard dependency, because of the weight from Assumption 3. I’d split it in two:
- a base library: the models (profiles, scopes, sessions), the openedx adaptors (content_libraries, submissions, location content) and the stable
api.py. Light, no litellm. This is the piece that could become a normal platform dependency, like openedx-core.
- a router library: the hard dependency on litellm, the llm_processors, the example profiles and the rest. Stays optional and upgrades on its own cadence.
That maps onto your proposal cleanly: the base library ships in the platform with no LLM backends, so the API is always importable and just returns “unavailable” until someone installs and configures a router. It also answers the weight concern, since the 210MB of litellm only lands on installs that opt into AI.
- XBlocks could declare certain version ranges of openedx-ai-extensions as dependencies for themselves, and import any public APIs that the AI framework chooses to expose. There is no need to use XBlock runtime services as the access mechanism.
Agreed that an xblock can just pin a version range of the base library and import the public API, and for some xblocks that’s the right call. I’d make the choice by how central AI is to the block:
- if AI is a core, non-optional feature of the xblock, importing the base library and depending on it is perfectly fine.
- if AI is an optional enhancement (ORA is my running example: it should keep working everywhere and only grow the AI grading step where the platform has the framework), then the runtime service is the cleaner door, because the xblock takes no dependency at all and degrades to None where the service isn’t there.
Either way it should be the same api.py underneath. The service isn’t a competing mechanism, it’s a thin optional adapter over the public API. So I think the question we’re actually left with is narrow: is that thin adapter (the xblock.service.v1 group in PR #927) worth carrying in XBlock core, given the base library is importable anyway? I’m happy to land wherever the community does on it.