mirror of
https://github.com/ghndrx/starlane-router.git
synced 2026-02-10 06:45:01 +00:00
feat: initial commit for starlane-router (FastAPI + Gradient)
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
.venv/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
.idea/
|
||||
.vscode/
|
||||
*.log
|
||||
.terraform/
|
||||
**/.pytest_cache/
|
||||
**/.mypy_cache/
|
||||
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal 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
79
README.md
Normal 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
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Package marker for app
|
||||
24
app/config.py
Normal file
24
app/config.py
Normal 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
34
app/gradient_client.py
Normal 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
69
app/main.py
Normal 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
21
app/router.py
Normal 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
19
k8s/config-secrets.yaml
Normal 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
59
k8s/deployment.yaml
Normal 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
20
k8s/ingress.yaml
Normal 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
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
httpx==0.27.0
|
||||
pydantic==2.8.2
|
||||
40
scripts/doctl_create_cluster.sh
Normal file
40
scripts/doctl_create_cluster.sh
Normal 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
|
||||
Reference in New Issue
Block a user