Skip to content

Commit fe94f01

Browse files
committed
refactor: Make code execution tool configurable
1 parent 03a8a78 commit fe94f01

File tree

10 files changed

+551
-51
lines changed

10 files changed

+551
-51
lines changed

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,49 @@ CUSTOM_LLM_CLIENT_SECRET = "your-client-secret"
9393
Your custom service must implement the expected OAuth2 client‑credentials flow and provide JSON endpoints
9494
for listing models, obtaining completions, and fetching tokens as used by `CustomLLMService`.
9595

96+
### Custom Code Execution Service (advanced)
97+
98+
The Coding XBlock can route code execution to a third‑party service instead of Judge0. The service is expected to be asynchronous, exposing a submit endpoint that returns a submission identifier, and a results endpoint that returns the execution result when available. Configure this via Django settings:
99+
100+
```python
101+
# e.g., in Tutor's extra settings
102+
AI_EVAL_CODE_EXECUTION_BACKEND = {
103+
'backend': 'custom',
104+
'custom_config': {
105+
'submit_endpoint': 'https://code-exec.example.com/api/submit',
106+
'results_endpoint': 'https://code-exec.example.com/api/results/{submission_id}',
107+
'languages_endpoint': 'https://code-exec.example.com/api/languages',
108+
'api_key': 'example-key',
109+
# For Bearer tokens (default): Authorization: Bearer <token>
110+
'auth_header_name': 'Authorization',
111+
'auth_scheme': 'Bearer',
112+
# Networking
113+
'timeout': 30,
114+
},
115+
}
116+
```
117+
118+
Header examples
119+
- Bearer (default): `Authorization: Bearer <API_KEY>` (use `auth_header_name='Authorization'`, `auth_scheme='Bearer'`)
120+
- Vendor header without scheme: `X-API-Key: <API_KEY>` (use `auth_header_name='X-API-Key'`, `auth_scheme=''`)
121+
122+
Notes
123+
- Asynchronous model: `submit_endpoint` should return an identifier (e.g., `submission_id` or `id`) that is later used to poll `results_endpoint`.
124+
- `results_endpoint` must include `{submission_id}` and return execution status and outputs when ready.
125+
- `languages_endpoint` is called during initialization to verify supported languages.
126+
- To use Judge0, remove the custom backend settings or set `backend='judge0'`. Provide the Judge0 API key in the XBlock configuration. Optionally set `judge0_config.base_url`; otherwise the default RapidAPI endpoint is used.
127+
128+
Example Judge0 configuration
129+
```python
130+
# Optional override for Judge0 base URL; API key is set per XBlock instance
131+
AI_EVAL_CODE_EXECUTION_BACKEND = {
132+
'backend': 'judge0',
133+
'judge0_config': {
134+
'base_url': 'https://judge0-ce.p.rapidapi.com',
135+
},
136+
}
137+
```
138+
96139
## Dependencies
97140
- [Judge0 API](https://judge0.com/)
98141
- [Monaco editor](https://github.com/microsoft/monaco-editor)

ai_eval/backends/__init__.py

Whitespace-only changes.

ai_eval/backends/base.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from abc import ABC, abstractmethod
2+
from typing import Dict, Any
3+
4+
5+
class CodeExecutionBackend(ABC):
6+
"""
7+
Abstract base class for code execution backends.
8+
"""
9+
10+
@abstractmethod
11+
def submit_code(self, code: str, language_label: str) -> str:
12+
"""
13+
Submit code for execution.
14+
15+
Args:
16+
code: The source code to execute
17+
language_label: Human-readable language label (e.g., "Python").
18+
Implementations map this to their own representation.
19+
20+
Returns:
21+
str: Submission ID for retrieving results
22+
"""
23+
pass
24+
25+
@abstractmethod
26+
def get_result(self, submission_id: str) -> Dict[str, Any]:
27+
"""
28+
Get execution result for a submission.
29+
30+
Args:
31+
submission_id: The submission ID from submit_code()
32+
33+
Returns:
34+
dict: Execution result containing:
35+
- status: dict with 'id' and 'description'
36+
- stdout: str or None
37+
- stderr: str or None
38+
- compile_output: str or None
39+
- time: str or None
40+
- memory: str or None
41+
"""
42+
pass
43+
44+

ai_eval/backends/custom.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import requests
2+
from typing import Dict, Any, Optional
3+
from .base import CodeExecutionBackend
4+
5+
6+
class CustomServiceBackend(CodeExecutionBackend):
7+
"""
8+
Generic custom code execution backend.
9+
"""
10+
11+
def __init__(
12+
self,
13+
submit_endpoint: str,
14+
results_endpoint: str,
15+
languages_endpoint: str,
16+
api_key: str = "",
17+
timeout: int = 30,
18+
auth_header_name: str = "Authorization",
19+
auth_scheme: Optional[str] = "Bearer",
20+
):
21+
self.submit_endpoint = submit_endpoint
22+
self.results_endpoint = results_endpoint
23+
self.languages_endpoint = languages_endpoint
24+
self.api_key = api_key
25+
self.timeout = timeout
26+
self.auth_header_name = auth_header_name
27+
self.auth_scheme = auth_scheme
28+
self._validate_languages()
29+
30+
def _get_headers(self) -> Dict[str, str]:
31+
"""
32+
Get headers for API requests.
33+
"""
34+
headers = {"Content-Type": "application/json"}
35+
if self.api_key:
36+
if self.auth_scheme:
37+
headers[self.auth_header_name] = f"{self.auth_scheme} {self.api_key}"
38+
else:
39+
headers[self.auth_header_name] = self.api_key
40+
return headers
41+
42+
def _validate_languages(self):
43+
"""
44+
Validate that static languages are supported by the custom service.
45+
"""
46+
try:
47+
response = requests.get(
48+
self.languages_endpoint,
49+
headers=self._get_headers(),
50+
timeout=self.timeout
51+
)
52+
response.raise_for_status()
53+
54+
service_languages = response.json()
55+
# Expected format: [{"id": "92", "name": "Python"}, ...] or [{"id": "python", "name": "Python"}, ...]
56+
service_language_names = {lang['name'].lower() for lang in service_languages}
57+
58+
from ai_eval.utils import SUPPORTED_LANGUAGE_MAP, LanguageLabels
59+
static_language_names = {name.lower() for name in SUPPORTED_LANGUAGE_MAP.keys()
60+
if name != LanguageLabels.HTML_CSS}
61+
62+
unsupported = static_language_names - service_language_names
63+
if unsupported:
64+
raise ValueError(
65+
f"Custom service does not support languages: {', '.join(unsupported)}. "
66+
)
67+
68+
except (requests.RequestException, KeyError, ValueError) as e:
69+
raise ValueError(f"Failed to validate supported languages: {e}")
70+
71+
def submit_code(self, code: str, language_label: str) -> str:
72+
"""
73+
Submit code to custom service for execution.
74+
"""
75+
# By default, send the language label; services will need to map as needed
76+
payload = {
77+
'code': code,
78+
'language': language_label
79+
}
80+
81+
try:
82+
response = requests.post(
83+
self.submit_endpoint,
84+
json=payload,
85+
headers=self._get_headers(),
86+
timeout=self.timeout
87+
)
88+
response.raise_for_status()
89+
90+
# Handle different response formats
91+
result = response.json()
92+
if 'submission_id' in result:
93+
return result['submission_id']
94+
elif 'id' in result:
95+
return str(result['id'])
96+
else:
97+
raise ValueError("Custom service response missing submission ID")
98+
99+
except requests.RequestException as e:
100+
raise ValueError(f"Failed to submit code for execution: {e}")
101+
except (KeyError, ValueError) as e:
102+
raise ValueError(f"Invalid response from custom service: {e}")
103+
104+
def get_result(self, submission_id: str) -> Dict[str, Any]:
105+
"""
106+
Get execution result from custom service.
107+
"""
108+
url = self.results_endpoint.format(submission_id=submission_id)
109+
110+
try:
111+
response = requests.get(
112+
url,
113+
headers=self._get_headers(),
114+
timeout=self.timeout
115+
)
116+
response.raise_for_status()
117+
118+
result = response.json()
119+
120+
# Map custom service response to standard format
121+
return {
122+
'status': {
123+
'id': result.get('status_code', 3),
124+
'description': result.get('status', 'Completed')
125+
},
126+
'stdout': result.get('stdout'),
127+
'stderr': result.get('stderr'),
128+
'compile_output': result.get('compile_error')
129+
}
130+
131+
except requests.RequestException as e:
132+
raise ValueError(f"Failed to get submission result: {e}")
133+
except (KeyError, ValueError) as e:
134+
raise ValueError(f"Invalid response from custom service: {e}")
135+

ai_eval/backends/factory.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from django.conf import settings
2+
from .judge0 import Judge0Backend
3+
from .custom import CustomServiceBackend
4+
5+
6+
class BackendFactory:
7+
"""
8+
Factory for creating code execution backends.
9+
"""
10+
11+
@classmethod
12+
def get_backend(cls, api_key: str = ""):
13+
"""
14+
Get the appropriate backend based on Django settings.
15+
16+
Args:
17+
api_key: Judge0 API key (only used for judge0 backend)
18+
19+
Returns:
20+
CodeExecutionBackend: Configured backend instance
21+
"""
22+
backend_config = getattr(settings, 'AI_EVAL_CODE_EXECUTION_BACKEND', {})
23+
24+
if backend_config.get('backend') == 'custom':
25+
config = backend_config.get('custom_config', {})
26+
return CustomServiceBackend(
27+
submit_endpoint=config.get('submit_endpoint', ''),
28+
results_endpoint=config.get('results_endpoint', ''),
29+
languages_endpoint=config.get('languages_endpoint', ''),
30+
api_key=config.get('api_key', ''),
31+
timeout=config.get('timeout', 30),
32+
auth_header_name=config.get('auth_header_name', 'Authorization'),
33+
auth_scheme=config.get('auth_scheme', 'Bearer'),
34+
)
35+
36+
# Default to judge0 backend
37+
judge0_config = backend_config.get('judge0_config', {})
38+
return Judge0Backend(
39+
api_key=api_key,
40+
base_url=judge0_config.get('base_url')
41+
)

ai_eval/backends/judge0.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import requests
2+
from typing import Dict, Any
3+
from .base import CodeExecutionBackend
4+
from ai_eval.utils import SUPPORTED_LANGUAGE_MAP, LanguageLabels
5+
6+
7+
class Judge0Backend(CodeExecutionBackend):
8+
"""
9+
Judge0 code execution backend.
10+
"""
11+
12+
def __init__(self, api_key: str = "", base_url: str = None):
13+
self.api_key = api_key
14+
self.base_url = base_url or "https://judge0-ce.p.rapidapi.com"
15+
16+
def submit_code(self, code: str, language_label: str) -> str:
17+
"""
18+
Submit code to Judge0 for execution.
19+
"""
20+
if not self.api_key:
21+
raise ValueError("Judge0 API key is required")
22+
23+
# Map the human-readable label to Judge0 numeric id
24+
try:
25+
judge0_id = SUPPORTED_LANGUAGE_MAP[language_label].judge0_id
26+
except KeyError as e:
27+
raise ValueError(f"Unsupported language: {language_label}") from e
28+
29+
url = f"{self.base_url}/submissions"
30+
headers = {
31+
'content-type': 'application/json',
32+
'x-rapidapi-key': self.api_key
33+
}
34+
payload = {
35+
'source_code': code,
36+
'language_id': int(judge0_id)
37+
}
38+
39+
try:
40+
response = requests.post(url, json=payload, headers=headers)
41+
response.raise_for_status()
42+
43+
result = response.json()
44+
if 'token' in result:
45+
return result['token']
46+
else:
47+
raise ValueError("Judge0 response missing submission token")
48+
49+
except requests.RequestException as e:
50+
raise ValueError(f"Failed to submit code to Judge0: {e}")
51+
except (KeyError, ValueError) as e:
52+
raise ValueError(f"Invalid response from Judge0: {e}")
53+
54+
def get_result(self, submission_id: str) -> Dict[str, Any]:
55+
"""
56+
Get execution result from Judge0.
57+
"""
58+
if not self.api_key:
59+
raise ValueError("Judge0 API key is required")
60+
61+
url = f"{self.base_url}/submissions/{submission_id}"
62+
headers = {'x-rapidapi-key': self.api_key}
63+
64+
try:
65+
response = requests.get(url, headers=headers)
66+
response.raise_for_status()
67+
68+
return response.json()
69+
70+
except requests.RequestException as e:
71+
raise ValueError(f"Failed to get submission result from Judge0: {e}")
72+
except (KeyError, ValueError) as e:
73+
raise ValueError(f"Invalid response from Judge0: {e}")
74+

0 commit comments

Comments
 (0)