From a991008666f62e24cc9a7d0d064403df9c2d5715 Mon Sep 17 00:00:00 2001 From: greg Date: Mon, 25 Aug 2025 20:08:09 -0700 Subject: [PATCH] feat: initial commit for starlane-router (FastAPI + Gradient) --- .gitignore | 13 ++++++ Dockerfile | 21 +++++++++ README.md | 79 +++++++++++++++++++++++++++++++++ app/__init__.py | 1 + app/config.py | 24 ++++++++++ app/gradient_client.py | 34 ++++++++++++++ app/main.py | 69 ++++++++++++++++++++++++++++ app/router.py | 21 +++++++++ k8s/config-secrets.yaml | 19 ++++++++ k8s/deployment.yaml | 59 ++++++++++++++++++++++++ k8s/ingress.yaml | 20 +++++++++ requirements.txt | 4 ++ scripts/doctl_create_cluster.sh | 40 +++++++++++++++++ 13 files changed, 404 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/config.py create mode 100644 app/gradient_client.py create mode 100644 app/main.py create mode 100644 app/router.py create mode 100644 k8s/config-secrets.yaml create mode 100644 k8s/deployment.yaml create mode 100644 k8s/ingress.yaml create mode 100644 requirements.txt create mode 100644 scripts/doctl_create_cluster.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96a5bb9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +.venv/ +*.pyc +.DS_Store +.env +.env.* +.idea/ +.vscode/ +*.log +.terraform/ +**/.pytest_cache/ +**/.mypy_cache/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3a99b27 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + POETRY_VIRTUALENVS_CREATE=false + +WORKDIR /app + +RUN apt-get update -y && apt-get install -y --no-install-recommends \ + curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY app ./app + +EXPOSE 8080 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4018ae6 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# a2a-router (FastAPI + DigitalOcean Gradient) + +Super-basic app that routes messages either to a local echo or to a DigitalOcean Gradient inference endpoint. + +## Features +- FastAPI with `/route` and `/healthz` +- Simple router: keyword/length heuristic or explicit `route_hint` +- Config via env/ConfigMap/Secret +- Docker + Kubernetes manifests (Deployment, Service, Ingress) +- Script to create a tiny DO Kubernetes cluster via `doctl` + +## Configure +Environment variables used by the app: +- `GRADIENT_ENDPOINT_URL`: Full URL to your Gradient inference endpoint +- `GRADIENT_API_KEY`: API key/token for Gradient +- `GRADIENT_AUTH_SCHEME`: `authorization_bearer` (default) or `x_api_key` +- `ROUTE_KEYWORDS`: comma-separated keywords to force Gradient routing + +## Local Run +```bash +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --host 0.0.0.0 --port 8080 +``` + +Test: +```bash +curl -s http://localhost:8080/healthz +curl -s -X POST http://localhost:8080/route -H 'Content-Type: application/json' \ + -d '{"message":"call the gradient model"}' | jq +``` + +## Build & Push Image +Replace `YOUR_REGISTRY/YOUR_IMAGE:tag` appropriately. +```bash +docker build -t YOUR_REGISTRY/YOUR_IMAGE:latest . +docker push YOUR_REGISTRY/YOUR_IMAGE:latest +``` + +## Create a Small DO K8s Cluster +Requires `doctl` and `kubectl`. +```bash +chmod +x scripts/doctl_create_cluster.sh +./scripts/doctl_create_cluster.sh a2a-cluster nyc1 s-1vcpu-2gb 1.29.1-do.0 1 +``` + +Install nginx ingress controller if needed: +```bash +kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.1/deploy/static/provider/cloud/deploy.yaml +``` + +## Deploy to Kubernetes +1) Create/update secrets and config: +```bash +kubectl apply -f k8s/config-secrets.yaml +``` + +2) Set the image and domain in manifests, then apply: +```bash +# edit k8s/deployment.yaml -> set image +# edit k8s/ingress.yaml -> set host domain +kubectl apply -f k8s/deployment.yaml +kubectl apply -f k8s/ingress.yaml +``` + +3) Get the ingress address and point your DNS (A/AAAA record) to it manually. +```bash +kubectl get ingress a2a-router -o wide +``` + +## API +- `POST /route` + - body: `{ message: string, route_hint?: 'local'|'gradient', metadata?: object }` + - response: `{ route: 'local'|'gradient', output: object }` +- `GET /healthz` + +## Notes +- To force Gradient usage, pass `route_hint: "gradient"` or include a keyword in `ROUTE_KEYWORDS`. +- If Gradient call fails, endpoint returns 502 with message. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..850e68a --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +# Package marker for app \ No newline at end of file diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..57604cc --- /dev/null +++ b/app/config.py @@ -0,0 +1,24 @@ +import os +from typing import List + + +def _get_env(name: str, default: str = "") -> str: + return os.getenv(name, default).strip() + + +def get_gradient_endpoint_url() -> str: + return _get_env("GRADIENT_ENDPOINT_URL") + + +def get_gradient_api_key() -> str: + return _get_env("GRADIENT_API_KEY") + + +def get_gradient_auth_scheme() -> str: + # 'authorization_bearer' or 'x_api_key' + return _get_env("GRADIENT_AUTH_SCHEME", "authorization_bearer").lower() + + +def get_route_keywords() -> List[str]: + raw = _get_env("ROUTE_KEYWORDS", "ai,model,ml,gpt,router,gradient") + return [kw.strip().lower() for kw in raw.split(",") if kw.strip()] \ No newline at end of file diff --git a/app/gradient_client.py b/app/gradient_client.py new file mode 100644 index 0000000..aa0182f --- /dev/null +++ b/app/gradient_client.py @@ -0,0 +1,34 @@ +from typing import Optional, Dict, Any +import httpx + + +async def call_gradient_inference( + *, + endpoint_url: str, + api_key: str, + message: str, + auth_scheme: str = "authorization_bearer", + timeout_seconds: float = 30.0, + extra_payload: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + headers = _build_headers(api_key=api_key, auth_scheme=auth_scheme) + + payload: Dict[str, Any] = {"input": message} + if extra_payload: + payload.update(extra_payload) + + async with httpx.AsyncClient(timeout=timeout_seconds) as client: + resp = await client.post(endpoint_url, headers=headers, json=payload) + resp.raise_for_status() + data = resp.json() + + # Normalize output shape + return {"raw": data} + + +def _build_headers(*, api_key: str, auth_scheme: str) -> Dict[str, str]: + scheme = (auth_scheme or "authorization_bearer").lower() + if scheme == "x_api_key": + return {"X-API-Key": api_key} + # default + return {"Authorization": f"Bearer {api_key}"} \ No newline at end of file diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..11345a0 --- /dev/null +++ b/app/main.py @@ -0,0 +1,69 @@ +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any +import asyncio + +from .config import ( + get_gradient_endpoint_url, + get_gradient_api_key, + get_gradient_auth_scheme, +) +from .gradient_client import call_gradient_inference +from .router import decide_route + + +app = FastAPI(title="a2a-router", version="0.1.0") + + +class RouteRequest(BaseModel): + message: str = Field(..., description="User message to route") + route_hint: Optional[str] = Field(None, description="Optional explicit route: 'gradient' or 'local'") + metadata: Optional[Dict[str, Any]] = Field(default=None, description="Optional JSON metadata") + + +class RouteResponse(BaseModel): + route: str + output: Dict[str, Any] + + +@app.get("/healthz") +async def healthz() -> Dict[str, str]: + return {"status": "ok"} + + +@app.post("/route", response_model=RouteResponse) +async def route_message(req: RouteRequest) -> RouteResponse: + route = decide_route(message=req.message, explicit_hint=req.route_hint) + + if route == "local": + return RouteResponse(route=route, output={"echo": req.message, "metadata": req.metadata or {}}) + + if route == "gradient": + endpoint_url = get_gradient_endpoint_url() + api_key = get_gradient_api_key() + auth_scheme = get_gradient_auth_scheme() + + if not endpoint_url: + raise HTTPException(status_code=500, detail="Missing GRADIENT_ENDPOINT_URL") + if not api_key: + raise HTTPException(status_code=500, detail="Missing GRADIENT_API_KEY") + + try: + result = await call_gradient_inference( + endpoint_url=endpoint_url, + api_key=api_key, + message=req.message, + auth_scheme=auth_scheme, + extra_payload={"metadata": req.metadata} if req.metadata else None, + ) + return RouteResponse(route=route, output=result) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Gradient call failed: {exc}") + + raise HTTPException(status_code=400, detail=f"Unknown route: {route}") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("app.main:app", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file diff --git a/app/router.py b/app/router.py new file mode 100644 index 0000000..88cc1a1 --- /dev/null +++ b/app/router.py @@ -0,0 +1,21 @@ +from typing import Optional +from .config import get_route_keywords + + +def decide_route(*, message: str, explicit_hint: Optional[str] = None) -> str: + if explicit_hint: + hint = explicit_hint.strip().lower() + if hint in {"gradient", "local"}: + return hint + + text = (message or "").strip().lower() + + # Simple heuristic: if message is long or has keywords, use gradient + if len(text) > 120: + return "gradient" + + for kw in get_route_keywords(): + if kw in text: + return "gradient" + + return "local" \ No newline at end of file diff --git a/k8s/config-secrets.yaml b/k8s/config-secrets.yaml new file mode 100644 index 0000000..aebcb16 --- /dev/null +++ b/k8s/config-secrets.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: a2a-config + labels: + app: a2a-router +data: + ROUTE_KEYWORDS: "ai,model,ml,gpt,router,gradient" +--- +apiVersion: v1 +kind: Secret +metadata: + name: a2a-secrets + labels: + app: a2a-router +stringData: + GRADIENT_ENDPOINT_URL: "https://REPLACE/api/infer" + GRADIENT_API_KEY: "REPLACE_ME" + GRADIENT_AUTH_SCHEME: "authorization_bearer" \ No newline at end of file diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..7e5996e --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: a2a-router + labels: + app: a2a-router +spec: + replicas: 1 + selector: + matchLabels: + app: a2a-router + template: + metadata: + labels: + app: a2a-router + spec: + containers: + - name: a2a-router + image: REPLACE_WITH_IMAGE + imagePullPolicy: IfNotPresent + env: + - name: GRADIENT_ENDPOINT_URL + valueFrom: + secretKeyRef: + name: a2a-secrets + key: GRADIENT_ENDPOINT_URL + - name: GRADIENT_API_KEY + valueFrom: + secretKeyRef: + name: a2a-secrets + key: GRADIENT_API_KEY + - name: GRADIENT_AUTH_SCHEME + valueFrom: + secretKeyRef: + name: a2a-secrets + key: GRADIENT_AUTH_SCHEME + - name: ROUTE_KEYWORDS + valueFrom: + configMapKeyRef: + name: a2a-config + key: ROUTE_KEYWORDS + ports: + - name: http + containerPort: 8080 +--- +apiVersion: v1 +kind: Service +metadata: + name: a2a-router + labels: + app: a2a-router +spec: + selector: + app: a2a-router + ports: + - name: http + port: 80 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/k8s/ingress.yaml b/k8s/ingress.yaml new file mode 100644 index 0000000..886c5d9 --- /dev/null +++ b/k8s/ingress.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: a2a-router + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: "2m" + nginx.ingress.kubernetes.io/rewrite-target: /$1 +spec: + rules: + - host: REPLACE_WITH_DOMAIN + http: + paths: + - path: /(.*) + pathType: Prefix + backend: + service: + name: a2a-router + port: + number: 80 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6525ed5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +httpx==0.27.0 +pydantic==2.8.2 \ No newline at end of file diff --git a/scripts/doctl_create_cluster.sh b/scripts/doctl_create_cluster.sh new file mode 100644 index 0000000..e127c18 --- /dev/null +++ b/scripts/doctl_create_cluster.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Requirements: +# - doctl installed and authenticated: `doctl auth init` +# - kubectl installed +# - DigitalOcean account with permissions + +# Usage: +# ./scripts/doctl_create_cluster.sh my-a2a-cluster nyc1 s-1vcpu-2gb 1.29.1-do.0 +# Defaults if not provided. + +CLUSTER_NAME=${1:-a2a-cluster} +REGION=${2:-nyc1} +NODE_SIZE=${3:-s-1vcpu-2gb} +VERSION=${4:-1.29.1-do.0} +NODE_COUNT=${5:-1} + +echo "Creating cluster: $CLUSTER_NAME in $REGION size=$NODE_SIZE nodes=$NODE_COUNT version=$VERSION" + +doctl kubernetes cluster create "$CLUSTER_NAME" \ + --region "$REGION" \ + --version "$VERSION" \ + --size "$NODE_SIZE" \ + --count "$NODE_COUNT" \ + --auto-upgrade=false \ + --wait + +# Fetch kubeconfig +mkdir -p "$HOME/.kube" +doctl kubernetes cluster kubeconfig save "$CLUSTER_NAME" + +echo "Cluster created and kubeconfig saved. Current context:" +KUBECONTEXT=$(kubectl config current-context) +echo "$KUBECONTEXT" + +echo "Optional: install nginx ingress controller" +cat <