Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ DEV_MODE=1 #0 for default model
DEV_MODEL_NAME=HuggingFaceTB/SmolLM-135M-Instruct
PROD_MODEL_NAME=Qwen/Qwen2-1.5B-Instruct

DOC_PATHS=["./docs/Pygame Documentation.pdf", "./docs/Python GTK+3 Documentation.pdf", "./docs/Sugar Toolkit Documentation.pdf"]
DOC_PATHS=["./rag_docs/Pygame Documentation.pdf", "./rag_docs/Python GTK+3 Documentation.pdf", "./rag_docs/Sugar Toolkit Documentation.pdf"]

PORT=8000

Expand All @@ -29,6 +29,13 @@ GOOGLE_CLIENT_SECRET=client_secret
OAUTH_REDIRECT_URI=http://localhost:8000/auth/callback
SESSION_SECRET_KEY=your_secret_key

# Static frontend deployed to GitHub Pages (leave blank to use the bundled
# Jinja templates for local dev).
# FRONTEND_URL=https://sugarlabs.github.io/sugar-ai/dashboard.html
# Comma-separated list of origins allowed to call the API (CORS) AND allowed
# as OAuth frontend_redirect targets.
ALLOWED_ORIGINS=http://localhost:8000,http://localhost:3000,http://127.0.0.1:8000

# Webhook based CI/CD
WEBHOOK_SECRET=your_webhook_secret
REPO_PATH_LOCALLY=/path/to/sugar-ai
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ COPY --from=builder /usr/local/bin /usr/local/bin
COPY *.py ./
COPY templates/ ./templates/
COPY static/ ./static/
COPY docs/ ./docs/
COPY rag_docs/ ./rag_docs/
COPY app/ ./app/
COPY .env* ./
RUN mkdir -p /app/data
Expand Down
4 changes: 3 additions & 1 deletion app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import logging

from app.auth import setup_oauth
from app.config import settings
from app.database import create_tables

# setup logging
Expand All @@ -33,9 +34,10 @@ def create_app() -> FastAPI:
TrustedHostMiddleware,
allowed_hosts=["localhost", "127.0.0.1", "*"]
)
# Browsers reject allow_credentials + "*", so pin to the explicit list.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=settings.allowed_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
Expand Down
9 changes: 8 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,14 @@ class Settings(BaseSettings):

# application settings
TEMPLATES_DIR: str = "templates"


FRONTEND_URL: Optional[str] = None
ALLOWED_ORIGINS: str = "http://localhost:8000,http://localhost:3000,http://127.0.0.1:8000"

@property
def allowed_origins_list(self) -> List[str]:
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",") if o.strip()]

class Config:
env_file = ".env"
extra = "allow" # this allows extra attribute if we have any
Expand Down
78 changes: 78 additions & 0 deletions app/routes/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,19 @@

from app.database import get_db, APIKey
from app.ai import RAGAgent, extract_answer_from_output
from app.auth import generate_api_key, get_current_user
from app.config import settings

# Pydantic models for chat completions
class ChatMessage(BaseModel):
role: str # "system", "user", "assistant"
content: str

class RequestKeyBody(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
email: str = Field(..., min_length=3, max_length=320)
reason: str = Field(..., min_length=1, max_length=2000)

class PromptedLLMRequest(BaseModel):
"""Request model for ask-llm-prompted endpoint"""
chat: bool = Field(False, description="Enable chat mode (uses messages instead of question)")
Expand Down Expand Up @@ -295,6 +301,78 @@ async def debug(
logger.error(f"ERROR - User: {user_info['name']} - Error: {str(e)}")
raise HTTPException(status_code=500, detail=f"Error processing request: {str(e)}")

@router.get("/api/health")
async def health():
return {"status": "ok"}

@router.get("/api/user")
async def get_user(
user_data: tuple = Depends(get_current_user),
request: Request = None
):
user, authenticated = user_data
if not authenticated or not user:
raise HTTPException(status_code=401, detail="Not authenticated")

today = datetime.now().date()
used = 0
if user.key in user_quotas and user_quotas[user.key].get("date") == today:
used = user_quotas[user.key].get("count", 0)
remaining = max(settings.MAX_DAILY_REQUESTS - used, 0)

picture = None
if request is not None:
session_user = request.session.get("user") if hasattr(request, "session") else None
if session_user:
picture = session_user.get("picture") or session_user.get("avatar_url")

return {
"name": user.name,
"email": user.email,
"api_key": user.key,
"can_change_model": user.can_change_model,
"is_active": user.is_active,
"picture": picture,
"quota": {"remaining": remaining, "total": settings.MAX_DAILY_REQUESTS}
}

@router.post("/api/request-key")
async def request_key_json(
body: RequestKeyBody,
db: Session = Depends(get_db)
):
api_key = generate_api_key()
new_key = APIKey(
key=api_key,
name=body.name,
email=body.email,
request_reason=body.reason,
approved=False,
is_active=False
)
db.add(new_key)
db.commit()
return {"status": "ok", "message": "Request submitted. You will be notified once it's approved."}

@router.get("/api/admin/keys")
async def admin_list_keys(
user_data: tuple = Depends(get_current_user),
db: Session = Depends(get_db)
):
user, authenticated = user_data
if not authenticated or not user or not user.can_change_model:
raise HTTPException(status_code=403, detail="Unauthorized")

pending = db.query(APIKey).filter(APIKey.approved == False, APIKey.is_active == False).all()
approved = db.query(APIKey).filter(APIKey.approved == True).all()
denied = db.query(APIKey).filter(APIKey.approved == False, APIKey.is_active == True).all()

return {
"pending": [k.to_dict() | {"id": k.id, "request_reason": k.request_reason} for k in pending],
"approved": [k.to_dict() | {"id": k.id} for k in approved],
"denied": [k.to_dict() | {"id": k.id, "request_reason": k.request_reason} for k in denied],
}

@router.post("/change-model")
async def change_model(
model: str,
Expand Down
42 changes: 35 additions & 7 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,33 @@
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
import logging
from urllib.parse import urlencode, urlparse

from app.database import get_db, APIKey
from app.auth import oauth, generate_api_key
from app.config import settings


def _validate_frontend_redirect(url: str) -> str:
# Only return the url if its origin is in ALLOWED_ORIGINS, otherwise an
# open redirect would let anyone phish an OAuth-issued API key.
if not url:
return ""
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return ""
origin = f"{parsed.scheme}://{parsed.netloc}"
return url if origin in settings.allowed_origins_list else ""


def _frontend_success_redirect(request: Request, api_key: str) -> RedirectResponse:
raw_target = request.session.pop("frontend_redirect", "")
target = _validate_frontend_redirect(raw_target) or settings.FRONTEND_URL
if target:
sep = "&" if "?" in target else "?"
return RedirectResponse(url=f"{target}{sep}{urlencode({'api_key': api_key})}")
return RedirectResponse(url="/dashboard")

router = APIRouter(tags=["auth"])

# setup logging
Expand All @@ -35,17 +57,23 @@ async def login(request: Request):
@router.get("/auth/github")
async def login_github(request: Request):
"""Redirect to GitHub OAuth login"""
request.session["frontend_redirect"] = _validate_frontend_redirect(
request.query_params.get("frontend_redirect", "")
)
redirect_uri = request.url_for("auth_callback", provider="github")
return await oauth.github.authorize_redirect(request, redirect_uri)

@router.get("/auth/google")
async def login_google(request: Request):
"""Redirect to Google OAuth login"""
try:
request.session["frontend_redirect"] = _validate_frontend_redirect(
request.query_params.get("frontend_redirect", "")
)
# create explicit redirect URI
base_url = str(request.base_url).rstrip("/")
redirect_uri = f"{base_url}/auth/callback/google"

logger.info(f"Google OAuth redirect using URI: {redirect_uri}")
return await oauth.google.authorize_redirect(request, redirect_uri=redirect_uri)
except Exception as e:
Expand Down Expand Up @@ -107,7 +135,7 @@ async def auth_callback(provider: str, request: Request, db: Session = Depends(g
return RedirectResponse(url="/oauth-login?error=No email found")

existing_key = db.query(APIKey).filter(APIKey.email == email).first()

if not existing_key:
# create new API key for OAuth user
api_key = generate_api_key()
Expand All @@ -122,14 +150,14 @@ async def auth_callback(provider: str, request: Request, db: Session = Depends(g
db.add(new_key)
db.commit()
logger.info(f"Created new API key for OAuth user: {email}")

# update API_KEYS in memory

settings.API_KEYS[api_key] = {"name": new_key.name, "can_change_model": new_key.can_change_model}
resolved_key = api_key
else:
# update in-memory API_KEYS for existing key
settings.API_KEYS[existing_key.key] = {"name": existing_key.name, "can_change_model": existing_key.can_change_model}

return RedirectResponse(url="/dashboard")
resolved_key = existing_key.key

return _frontend_success_redirect(request, resolved_key)
except Exception as e:
logger.error(f"OAuth error: {str(e)}")
return RedirectResponse(url="/oauth-login?error=Authentication failed")
Expand Down
Loading