Skip to content

Commit 2b4cf88

Browse files
committed
Initial commit for Model-as-a-Service
1 parent 8f24fd6 commit 2b4cf88

21 files changed

Lines changed: 1575 additions & 0 deletions

AGENTS.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Trailblazing Turtle — Agent Instructions
2+
3+
## Overview
4+
Django 5.2 web portal for HPC/OpenStack cluster usage visualization. Modular architecture — each feature is a Django app that can be enabled/disabled.
5+
6+
## Key Commands
7+
8+
```bash
9+
# Lint (CI uses max-line-length 200, excludes settings/)
10+
flake8 --max-line-length 200 --extend-exclude 'userportal/settings/,example/' .
11+
12+
# Run tests (requires running database, see Testing section)
13+
python manage.py test
14+
15+
# Run a single app's tests
16+
python manage.py test jobstats
17+
18+
# Build docs
19+
mkdocs build --strict
20+
21+
# Production static files
22+
python manage.py collectstatic --noinput
23+
python manage.py compilemessages
24+
```
25+
26+
## Settings — Critical Gotcha
27+
Settings are **split across files** in `userportal/settings/`. The `userportal/settings.py` loader glob-imports all `*.py` files in that directory, **executing them in alphabetical order**. Local overrides go in `*-local.py` (gitignored).
28+
29+
Production deploys copy `99-local.py` at runtime via `run-django-production.sh`.
30+
31+
Flake8 on CI **excludes** `userportal/settings/` entirely.
32+
33+
## Multi-Database Architecture
34+
Three databases configured in `userportal/settings/20-databases.py`:
35+
- `default` — MySQL (Django internals)
36+
- `slurm` — MySQL/SacctManager (Slurm accounting, read-only)
37+
- `ldap` — LDAP via django-ldapdb
38+
39+
Routing is handled by `database_routers.dbrouters.DbRouter`. Tests that touch the slurm database must declare `databases = '__all__'` (see `tests/tests.py`).
40+
41+
## Testing Quirks
42+
- Custom test runner (`userportal.testrunner.CustomTestRunner`) **skips creating a test DB** for the `slurm` alias. Tests run against the real slurm database.
43+
- Base test utilities live in `tests/tests.py` (`CustomTestCase` provides logged-in user/admin clients). All module tests inherit from it.
44+
- Each Django app has its own `tests.py` file (14 modules total).
45+
- The `90-tests.py` settings file defines test fixtures (`TESTS_USER`, `TESTS_JOBSTATS`, etc.).
46+
- Tests cannot run without a live MySQL database for the slurm backend.
47+
48+
## Patches Applied at Build Time
49+
The `Containerfile` applies two patches to installed packages:
50+
- `ldapdb.patch` — disables LDAP paged results (breaks pagination cookie loop)
51+
- `dbcheck.patch` — disables Django DB version check (MariaDB on EL8 compatibility)
52+
53+
These patches are required for the container image to function. Local dev may need them too.
54+
55+
## Module Structure
56+
Each feature module (`jobstats`, `top`, `nodes`, `accountstats`, etc.) is a self-contained Django app with:
57+
- `models.py`, `views.py`, `urls.py`, `tests.py`, `serializers.py`
58+
- Optional `migrations/`, `templates/`, `static/`
59+
60+
URLs are registered conditionally in `userportal/urls.py` based on `INSTALLED_APPS`. Adding a new module requires: enabling in settings, registering URLs, and adding to `mkdocs.yml` nav.
61+
62+
## i18n
63+
- French translations in `locale/fr/LC_MESSAGES/`
64+
- Run `python manage.py compilemessages` after updating `.po` files
65+
- Codespell CI skips the French `.po` files and `mii-parser.py`

docs/maas.md

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
# Model-as-a-Service (MaaS)
2+
3+
The MaaS module provides LLM inference usage metering for an inference-as-a-service backend. Users create API keys via the web UI, and an inference backend authenticates each request with a user's API key, validates it, and reports usage via a REST API.
4+
5+
## Screenshots
6+
7+
### User page
8+
Shows API keys, usage summary, and recent usage records for a user.
9+
10+
### API key creation
11+
API keys are shown in plaintext only once at creation time with a copy-to-clipboard button.
12+
13+
### Provider management
14+
Staff can manage inference providers, each with a secret key used to associate usage with the correct provider.
15+
16+
## Features
17+
18+
- **API key management**: Create, view, and revoke API keys with optional expiration dates
19+
- **Provider management**: Admin-managed providers with secret keys
20+
- **Usage recording**: Record inference usage with tokens, cost, latency, and model info
21+
- **Key validation**: Pre-inference endpoint to verify both user key and provider
22+
- **Show-once keys**: API keys are displayed only once at creation
23+
- **User isolation**: Users can only see their own keys; staff/admin can browse any user
24+
25+
## How It Works
26+
27+
```
28+
End User (Bearer key) → Inference Backend → Portal API
29+
├── Verify key + provider
30+
└── POST usage record
31+
```
32+
33+
The inference backend receives the user's Bearer token, forwards it to the portal for validation, and includes the provider's secret key to associate the usage with the correct provider.
34+
35+
---
36+
37+
## API Reference
38+
39+
All API endpoints return JSON. Authentication uses `Authorization: Bearer <api_key>`.
40+
41+
### Verify Key + Provider
42+
43+
Validate a user's API key and provider before running inference.
44+
45+
**POST** `/api/maas/verify/`
46+
47+
**Headers:**
48+
- `Authorization: Bearer <user_api_key>`
49+
50+
**Request body:**
51+
```json
52+
{
53+
"provider_key": "<provider-secret-key>"
54+
}
55+
```
56+
57+
**Response 200:**
58+
```json
59+
{
60+
"valid": true,
61+
"user": "username",
62+
"expires_at": "2026-12-31T23:59:59Z",
63+
"provider": {
64+
"id": 1,
65+
"name": "OpenAI Production",
66+
"url": "https://inference.example.com"
67+
}
68+
}
69+
```
70+
71+
**Response 401** (invalid/expired user key):
72+
```json
73+
{ "detail": "Invalid API key" }
74+
```
75+
76+
**Response 400** (invalid provider key):
77+
```json
78+
{ "error": "Invalid provider key" }
79+
```
80+
81+
**cURL:**
82+
```bash
83+
curl -X POST http://localhost/api/maas/verify/ \
84+
-H "Authorization: Bearer <user-api-key-value>" \
85+
-H "Content-Type: application/json" \
86+
-d '{"provider_key": "<provider-secret-key>"}'
87+
```
88+
89+
### Record Usage
90+
91+
Record the usage for a completed inference request.
92+
93+
**POST** `/api/maas/usage/`
94+
95+
**Headers:**
96+
- `Authorization: Bearer <user_api_key>`
97+
98+
**Request body:**
99+
```json
100+
{
101+
"provider_key": "<provider-secret-key>",
102+
"model": "gpt-4o",
103+
"endpoint": "/v1/chat/completions",
104+
"input_tokens": 150,
105+
"output_tokens": 320,
106+
"cost": 0.00475,
107+
"latency_ms": 450,
108+
"request_id": "550e8400-e29b-41d4-a716-446655440000"
109+
}
110+
```
111+
112+
| Field | Required | Type | Description |
113+
|-------|----------|------|-------------|
114+
| `provider_key` | Yes | string | Secret key identifying the provider |
115+
| `model` | Yes | string | Model name (e.g., `gpt-4o`, `claude-3`) |
116+
| `endpoint` | Yes | string | API endpoint used (e.g., `/v1/chat/completions`) |
117+
| `input_tokens` | Yes | integer | Number of input tokens consumed |
118+
| `output_tokens` | Yes | integer | Number of output tokens generated |
119+
| `cost` | No | number | Cost of the request (if applicable) |
120+
| `latency_ms` | Yes | integer | Response time in milliseconds |
121+
| `request_id` | No | string | Unique request identifier (auto-generated if omitted) |
122+
123+
**Response 201:**
124+
```json
125+
{
126+
"id": 1,
127+
"user": "username",
128+
"provider_name": "OpenAI Production",
129+
"request_id": "550e8400-e29b-41d4-a716-446655440000",
130+
"model": "gpt-4o",
131+
"endpoint": "/v1/chat/completions",
132+
"input_tokens": 150,
133+
"output_tokens": 320,
134+
"cost": 0.00475,
135+
"latency_ms": 450,
136+
"created_at": "2026-05-16T12:00:00Z"
137+
}
138+
```
139+
140+
**cURL:**
141+
```bash
142+
curl -X POST http://localhost/api/maas/usage/ \
143+
-H "Authorization: Bearer <user-api-key-value>" \
144+
-H "Content-Type: application/json" \
145+
-d '{
146+
"provider_key": "<provider-secret-key>",
147+
"model": "gpt-4o",
148+
"endpoint": "/v1/chat/completions",
149+
"input_tokens": 150,
150+
"output_tokens": 320,
151+
"cost": 0.00475,
152+
"latency_ms": 450
153+
}'
154+
```
155+
156+
### Query Usage
157+
158+
Retrieve usage records for the authenticated user.
159+
160+
**GET** `/api/maas/usage/`
161+
162+
**Headers:**
163+
- `Authorization: Bearer <user_api_key>`
164+
165+
**Query parameters:**
166+
| Parameter | Description |
167+
|-----------|-------------|
168+
| `model` | Filter by model name |
169+
| `start` | Filter by start date (ISO format) |
170+
| `end` | Filter by end date (ISO format) |
171+
172+
**Response 200:**
173+
```json
174+
[
175+
{
176+
"id": 1,
177+
"user": "username",
178+
"provider_name": "OpenAI Production",
179+
"request_id": "550e8400-...",
180+
"model": "gpt-4o",
181+
"endpoint": "/v1/chat/completions",
182+
"input_tokens": 150,
183+
"output_tokens": 320,
184+
"cost": 0.00475,
185+
"latency_ms": 450,
186+
"created_at": "2026-05-16T12:00:00Z"
187+
}
188+
]
189+
```
190+
191+
**cURL:**
192+
```bash
193+
curl -H "Authorization: Bearer <user-api-key-value>" \
194+
"http://localhost/api/maas/usage/?model=gpt-4o&start=2026-05-01"
195+
```
196+
197+
### Revoke Key (Public)
198+
199+
**POST** `/api/maas/key/revoke/`
200+
201+
A public endpoint that revokes a key by presenting it in the auth header. Always returns `200` — never reveals whether the key existed — to prevent key enumeration.
202+
203+
**Headers:**
204+
- `Authorization: Bearer <key_to_revoke>`
205+
206+
**Response 200:**
207+
```json
208+
{}
209+
```
210+
211+
**cURL:**
212+
```bash
213+
curl -X POST -H "Authorization: Bearer <key-to-revoke>" \
214+
http://localhost/api/maas/key/revoke/
215+
```
216+
217+
## Requirements
218+
219+
No external dependencies beyond Django REST Framework (already included in the project).

maas/__init__.py

Whitespace-only changes.

maas/admin.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.contrib import admin
2+
from maas.models import MAASProvider, MAASApiKey, MAASUsageRecord
3+
4+
5+
@admin.register(MAASProvider)
6+
class MAASProviderAdmin(admin.ModelAdmin):
7+
list_display = ('name', 'url', 'is_active', 'created_at', 'updated_at')
8+
list_filter = ('is_active',)
9+
search_fields = ('name',)
10+
readonly_fields = ('created_at', 'updated_at', 'key_hash')
11+
12+
13+
@admin.register(MAASApiKey)
14+
class MAASApiKeyAdmin(admin.ModelAdmin):
15+
list_display = ('user', 'name', 'is_active', 'is_expired', 'created_at', 'expires_at', 'last_used_at')
16+
list_filter = ('is_active',)
17+
search_fields = ('user__username', 'name')
18+
readonly_fields = ('created_at', 'last_used_at', 'key_hash')
19+
20+
21+
@admin.register(MAASUsageRecord)
22+
class MAASUsageRecordAdmin(admin.ModelAdmin):
23+
list_display = ('request_id', 'api_key', 'provider', 'model', 'input_tokens', 'output_tokens', 'cost', 'latency_ms', 'created_at')
24+
list_filter = ('provider', 'model')
25+
search_fields = ('request_id', 'model')
26+
readonly_fields = ('created_at',)

maas/apps.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from django.apps import AppConfig
2+
3+
4+
class MaasConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'maas'
7+
verbose_name = 'Model-as-a-Service'

0 commit comments

Comments
 (0)