Skip to content
Closed
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
70 changes: 70 additions & 0 deletions .github/workflows/api_ml.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
name: api_ml

on:
push:
paths:
- bases/bot_detector/api_ml/**
- components/bot_detector/kafka/**
- components/bot_detector/structs/**
- components/bot_detector/logfmt/**
- projects/api_ml/**
workflow_dispatch:
inputs:
environment:
description: "Deploy environment"
required: true
default: "test"
type: choice
options:
- test
- deploy to prd
env:
QUAY_REGISTERY: quay.io/repository/bot_detector/bd-ml
DOCKERFILE: ./projects/api_ml/Dockerfile
DEPLOYMENT_FILE: bd-ml-prd/deployment.yaml
PROJECT_DIR: projects/api_ml

jobs:
# messy AF
env_vars:
runs-on: [self-hosted, "hetzner"]
outputs:
QUAY_REGISTERY: ${{ env.QUAY_REGISTERY }}
DOCKERFILE: ${{ env.DOCKERFILE }}
DEPLOYMENT_FILE: ${{ env.DEPLOYMENT_FILE }}
PROJECT_DIR: ${{ env.PROJECT_DIR }}
steps:
- run: |
echo "Exposing env vars to other jobs"
echo "QUAY_REGISTERY=${{ env.QUAY_REGISTERY }}"
echo "DOCKERFILE=${{ env.DOCKERFILE }}"
echo "DEPLOYMENT_FILE=${{ env.DEPLOYMENT_FILE }}"
echo "PROJECT_DIR=${{ env.PROJECT_DIR }}"

common:
uses: ./.github/workflows/_common.yml
needs: [env_vars]
with:
project_dir: ${{ needs.env_vars.outputs.PROJECT_DIR }}

build:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'deploy to prd' }}
uses: ./.github/workflows/_build.yml
needs: [common, env_vars]
with:
registry: ${{ needs.env_vars.outputs.QUAY_REGISTERY }}
environment: ${{ (github.event.inputs.environment == 'deploy to prd' && 'prd') }}
BUILD_ARGS: "--target=production"
DOCKERFILE: ${{ needs.env_vars.outputs.DOCKERFILE }}
secrets:
QUAY_REGISTERY_PASSWORD: ${{ secrets.QUAY_REGISTERY_PASSWORD }}

deploy:
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'deploy to prd' }}
uses: ./.github/workflows/_deploy.yml
needs: [build, env_vars]
with:
registry: ${{ needs.env_vars.outputs.QUAY_REGISTERY }}
value_file: ${{ needs.env_vars.outputs.DEPLOYMENT_FILE }}
secrets:
HETZNER_ACTIONS_RUNNER_TOKEN: ${{ secrets.HETZNER_ACTIONS_RUNNER_TOKEN }}
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,6 @@ cython_debug/
_mysql/docker-entrypoint-initdb.d/2025-02-23_19-38_players_export.sql
_mysql_data/src/2025-02-23_19-38_players_export.sql

test.py
test.py
_minio_data/.gitkeep
_minio_data/*
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit"
"source.organizeImports": "explicit",
"source.fixAll.ruff": "explicit"
}
},
"isort.args": [
Expand Down
6 changes: 6 additions & 0 deletions bases/bot_detector/api_ml/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from fastapi import APIRouter

from . import v1

router = APIRouter()
router.include_router(v1.router, prefix="/v1")
8 changes: 8 additions & 0 deletions bases/bot_detector/api_ml/api/v1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import APIRouter

from .health import health
from .models import models

router = APIRouter()
router.include_router(health.router, tags=["health"])
router.include_router(models.router, tags=["models"])
11 changes: 11 additions & 0 deletions bases/bot_detector/api_ml/api/v1/health/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import logging

from fastapi import APIRouter, status

router = APIRouter()
logger = logging.getLogger(__name__)


@router.get("/health", status_code=status.HTTP_200_OK)
async def health():
return {"status": "ok"}
53 changes: 53 additions & 0 deletions bases/bot_detector/api_ml/api/v1/models/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import logging

from bot_detector.api_ml.core.config import get_models
from fastapi import APIRouter, Depends, HTTPException
from mlflow.pyfunc import PyFuncModel

router = APIRouter()
logger = logging.getLogger(__name__)


@router.get("/models")
def list_models(models: dict = Depends(get_models)):
return {"models": list(models.keys())}


@router.get("/models/{model_name}")
def get_model_info(model_name: str, models: dict = Depends(get_models)):
model: PyFuncModel = models.get(model_name, None)
if model is None:
raise HTTPException(status_code=404, detail=f"Model: {model_name} not found")
info = {}
if model._model_meta is not None:
if (
hasattr(model._model_meta, "run_id")
and model._model_meta.run_id is not None
):
info["run_id"] = model._model_meta.run_id
if (
hasattr(model._model_meta, "artifact_path")
and model._model_meta.artifact_path is not None
):
info["artifact_path"] = model._model_meta.artifact_path
info["flavor"] = model._model_meta.flavors

return {
"model": model_name,
"status": "loaded",
"input_example": model.input_example,
"model_info": info,
}


@router.post("/models/{model_name}/predict")
def predict(
model_name: str,
data: list[dict],
models: dict[str, PyFuncModel] = Depends(get_models),
):
model = models.get(model_name, None)
if model is None:
raise HTTPException(status_code=404, detail=f"Model: {model_name} not found")
prediction = model.predict(data)
return {"model": model_name, "prediction": prediction}
5 changes: 5 additions & 0 deletions bases/bot_detector/api_ml/core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from bot_detector import logfmt

from . import config

__all__ = ["logfmt", "config"]
18 changes: 18 additions & 0 deletions bases/bot_detector/api_ml/core/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from mlflow.pyfunc import PyFuncModel
from pydantic import Field
from pydantic_settings import BaseSettings


class Settings(BaseSettings):
MLFLOW_S3_ENDPOINT_URL: str = Field(default=...)
AWS_ACCESS_KEY_ID: str = Field(default=...)
AWS_SECRET_ACCESS_KEY: str = Field(default=...)
MODEL_URIS: dict[str, str] = {}


def get_models() -> dict[str, PyFuncModel]:
return models


models: dict = {}
SETTINGS = Settings()
Empty file.
4 changes: 4 additions & 0 deletions bases/bot_detector/api_ml/core/fastapi/middleware/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .logging import LoggingMiddleware
from .metrics import PrometheusMiddleware

__all__ = ["LoggingMiddleware", "PrometheusMiddleware"]
28 changes: 28 additions & 0 deletions bases/bot_detector/api_ml/core/fastapi/middleware/logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import logging
import time

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware

logger = logging.getLogger("logging")


class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time

query_params_list = [
(key, value if key != "token" else "***")
for key, value in request.query_params.items()
]

logger.info(
{
"url": request.url.path,
"params": query_params_list,
"process_time": f"{process_time:.4f}",
}
)
return response
42 changes: 42 additions & 0 deletions bases/bot_detector/api_ml/core/fastapi/middleware/metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from fastapi import FastAPI, Request
from prometheus_client import Counter, Histogram

import time
from starlette.middleware.base import BaseHTTPMiddleware

# Create FastAPI app
app = FastAPI()

# Define Prometheus metrics
REQUEST_COUNT = Counter(
"request_count", "Total number of requests", ["method", "endpoint", "http_status"]
)
REQUEST_LATENCY = Histogram(
"request_latency_seconds", "Latency of requests in seconds", ["method", "endpoint"]
)


# Middleware for Prometheus metrics logging
class PrometheusMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Start timer for request latency
start_time = time.time()

# Process request
response = await call_next(request)

# Calculate request latency
latency = time.time() - start_time

# Update Prometheus metrics
REQUEST_COUNT.labels(
method=request.method,
endpoint=request.url.path,
http_status=response.status_code,
).inc()
REQUEST_LATENCY.labels(
method=request.method,
endpoint=request.url.path,
).observe(latency)

return response
76 changes: 76 additions & 0 deletions bases/bot_detector/api_ml/core/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import logging
from contextlib import asynccontextmanager

import mlflow
from bot_detector.api_ml import api
from bot_detector.api_ml.core.config import SETTINGS, models
from bot_detector.api_ml.core.fastapi.middleware import (
LoggingMiddleware,
PrometheusMiddleware,
)
from fastapi import FastAPI
from fastapi.middleware import Middleware
from fastapi.middleware.cors import CORSMiddleware
from prometheus_client import start_wsgi_server

logger = logging.getLogger(__name__)


def init_routers(_app: FastAPI) -> None:
_app.include_router(api.router)


def make_middleware() -> list[Middleware]:
middleware = [
Middleware(
CORSMiddleware,
allow_origins=[
"http://osrsbotdetector.com/",
"https://osrsbotdetector.com/",
"http://localhost",
"http://localhost:8080",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
),
Middleware(LoggingMiddleware),
Middleware(PrometheusMiddleware),
]
return middleware


@asynccontextmanager
async def lifespan(app: FastAPI):
for name, uri in SETTINGS.MODEL_URIS.items():
print(f"Loading model: {name} from {uri}")
models[name] = mlflow.pyfunc.load_model(uri)

logger.info("starting!")
yield
logger.info("stopping")

models.clear()
print("Models unloaded")


def create_app() -> FastAPI:
_app = FastAPI(
title="Bot-Detector-API",
description="Bot-Detector-API",
middleware=make_middleware(),
lifespan=lifespan,
)
init_routers(_app=_app)
return _app


app = create_app()


start_wsgi_server(port=8000)


@app.get("/")
async def root():
return {"message": "Hello World"}
Loading
Loading