feat: initial commit for starlane-router (FastAPI + Gradient)

This commit is contained in:
greg
2025-08-25 20:08:09 -07:00
commit a991008666
13 changed files with 404 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
__pycache__/
.venv/
*.pyc
.DS_Store
.env
.env.*
.idea/
.vscode/
*.log
.terraform/
**/.pytest_cache/
**/.mypy_cache/

21
Dockerfile Normal file
View File

@@ -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"]

79
README.md Normal file
View File

@@ -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.

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Package marker for app

24
app/config.py Normal file
View File

@@ -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()]

34
app/gradient_client.py Normal file
View File

@@ -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}"}

69
app/main.py Normal file
View File

@@ -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)

21
app/router.py Normal file
View File

@@ -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"

19
k8s/config-secrets.yaml Normal file
View File

@@ -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"

59
k8s/deployment.yaml Normal file
View File

@@ -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

20
k8s/ingress.yaml Normal file
View File

@@ -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

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
httpx==0.27.0
pydantic==2.8.2

View File

@@ -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 <<EOF
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.10.1/deploy/static/provider/cloud/deploy.yaml
EOF