- Three inbound routes from docs/hub-api.md: POST /ask, POST /approve, GET /health. - Five-step pipeline in src/hub, with packages hear, understand, check, route, answer. - Routing through OmniRoute with the capability list and routing.md. The never-route control check runs before any LLM call. - One real destination: Jervis house TTS, started out of band. - Fake /ask project (src/tests/fakeproject) for the round-trip test. - Append-only, size-capped request log. Bearer auth, source match, rate limit. - Tests: all packages pass, including the fake /ask round trip. Race clean. - Dockerfile and compose updated to mount routing.md and secrets, log on tmpfs. Co-Authored-By: Paperclip <noreply@paperclip.ing>
406 lines
11 KiB
Go
406 lines
11 KiB
Go
// Command hub is the Central AI Hub service.
|
|
//
|
|
// It serves the three frozen inbound routes from docs/hub-api.md:
|
|
//
|
|
// POST /ask — hear, understand, check, route, answer
|
|
// POST /approve — decide one Hub-owned approval
|
|
// GET /health — liveness only
|
|
//
|
|
// The pipeline lives in package hub. This file is the HTTP layer only.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hub/apierr"
|
|
"hub/check"
|
|
"hub/config"
|
|
"hub/destinations"
|
|
"hub/hear"
|
|
"hub/hub"
|
|
"hub/logbook"
|
|
"hub/route"
|
|
"hub/understand"
|
|
)
|
|
|
|
const (
|
|
serviceName = "hub"
|
|
serviceVersion = "1"
|
|
|
|
maxBodyBytes = 64 * 1024
|
|
)
|
|
|
|
// server is the HTTP surface.
|
|
type server struct {
|
|
svc *hub.Service
|
|
cfg *config.Config
|
|
rate *rateLimiter
|
|
}
|
|
|
|
func main() {
|
|
hc := flag.Bool("healthcheck", false, "Call this process's /health and exit.")
|
|
flag.Parse()
|
|
|
|
cfg := config.Load()
|
|
if *hc {
|
|
healthcheck(cfg)
|
|
return
|
|
}
|
|
|
|
rules, err := understand.LoadRules(cfg.RoutingFile)
|
|
if err != nil {
|
|
log.Printf("routing: using built-in rules (%v)", err)
|
|
}
|
|
|
|
lb, err := logbook.New(cfg.LogPath, cfg.LogMaxBytes, cfg.LogKeepDays)
|
|
if err != nil {
|
|
log.Fatalf("cannot open log %s: %v", cfg.LogPath, err)
|
|
}
|
|
defer lb.Close()
|
|
|
|
projects := map[string]route.ProjectQuerier{}
|
|
for name, p := range cfg.Projects {
|
|
projects[name] = destinations.NewProjectClient(name, p.URL, p.Token, cfg.ProjectTimeout)
|
|
}
|
|
speaker := destinations.NewSpeaker(cfg.JervisURL, cfg.TTSTimeout)
|
|
paperclip := destinations.NewPaperclip(cfg.PaperclipURL, cfg.PaperclipKey, cfg.PaperclipCompanyID, cfg.ApproveTimeout)
|
|
approvals := hub.NewApprovals(10 * time.Minute)
|
|
|
|
dispatcher := &route.Dispatcher{
|
|
Rules: rules,
|
|
Projects: projects,
|
|
Speaker: speaker,
|
|
Paperclip: paperclip,
|
|
Approvals: approvals,
|
|
}
|
|
svc := &hub.Service{
|
|
Config: cfg,
|
|
Rules: rules,
|
|
Router: understand.NewOmniRouter(cfg.OmniURL, cfg.OmniKey, cfg.OmniModel, cfg.LLMTimeout),
|
|
Dispatcher: dispatcher,
|
|
Approvals: approvals,
|
|
Log: lb,
|
|
Speaker: speaker,
|
|
Paperclip: paperclip,
|
|
Started: time.Now(),
|
|
HealthProbe: omniProbe(cfg),
|
|
}
|
|
|
|
s := &server{svc: svc, cfg: cfg, rate: newRateLimiter(cfg.RatePerMin)}
|
|
|
|
srv := &http.Server{
|
|
Addr: ":" + cfg.Port,
|
|
Handler: s.routes(),
|
|
ReadHeaderTimeout: 1 * time.Second,
|
|
ReadTimeout: cfg.AskTimeout,
|
|
// WriteTimeout must be longer than the /ask deadline, or the server can
|
|
// cut the reply off when the handler finishes at the deadline.
|
|
WriteTimeout: cfg.AskTimeout + 3*time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
log.Printf("%s %s listening on :%s", serviceName, serviceVersion, cfg.Port)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
log.Fatalf("%s stopped: %v", serviceName, err)
|
|
}
|
|
}
|
|
|
|
// routes builds the HTTP multiplexer.
|
|
func (s *server) routes() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/ask", s.handleAsk)
|
|
mux.HandleFunc("/approve", s.handleApprove)
|
|
mux.HandleFunc("/health", s.handleHealth)
|
|
mux.HandleFunc("/", notFound)
|
|
return mux
|
|
}
|
|
|
|
// handleAsk serves POST /ask.
|
|
func (s *server) handleAsk(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "", "bad_request", "Use POST for this route.")
|
|
return
|
|
}
|
|
if !requireJSON(w, r) {
|
|
return
|
|
}
|
|
source, ok := s.auth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ok, retry := s.rate.allow(source); !ok {
|
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", retry))
|
|
writeError(w, http.StatusTooManyRequests, "", "rate_limited", "Too many requests. Try again shortly.")
|
|
return
|
|
}
|
|
|
|
body, ok := readBody(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
req, apiErr := hear.Parse(body)
|
|
if apiErr != nil {
|
|
writeAPIError(w, apiErr, "")
|
|
return
|
|
}
|
|
if apiErr := check.Ask(req, source); apiErr != nil {
|
|
writeAPIError(w, apiErr, req.RequestID)
|
|
return
|
|
}
|
|
|
|
start := time.Now()
|
|
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.AskTimeout)
|
|
defer cancel()
|
|
|
|
resp := s.svc.Ask(ctx, req)
|
|
s.svc.LogAsk(req, resp, time.Since(start))
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleApprove serves POST /approve. Only HUB_TOKEN_ADMIN works.
|
|
func (s *server) handleApprove(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "", "bad_request", "Use POST for this route.")
|
|
return
|
|
}
|
|
if !requireJSON(w, r) {
|
|
return
|
|
}
|
|
source, ok := s.auth(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ok, retry := s.rate.allow(source); !ok {
|
|
w.Header().Set("Retry-After", fmt.Sprintf("%d", retry))
|
|
writeError(w, http.StatusTooManyRequests, "", "rate_limited", "Too many requests. Try again shortly.")
|
|
return
|
|
}
|
|
if apiErr := check.Admin(source); apiErr != nil {
|
|
writeAPIError(w, apiErr, "")
|
|
return
|
|
}
|
|
|
|
body, ok := readBody(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
req, apiErr := hub.ParseApprove(body)
|
|
if apiErr != nil {
|
|
writeAPIError(w, apiErr, "")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.ApproveTimeout)
|
|
defer cancel()
|
|
|
|
resp, apiErr := s.svc.Approve(ctx, req)
|
|
if apiErr != nil {
|
|
writeAPIError(w, apiErr, "")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
// handleHealth serves GET /health. No authentication.
|
|
func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "", "bad_request", "Use GET for this route.")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), s.cfg.HealthTimeout)
|
|
defer cancel()
|
|
|
|
resp := s.svc.Health(ctx)
|
|
code := http.StatusOK
|
|
if !resp.OK {
|
|
code = http.StatusServiceUnavailable
|
|
}
|
|
writeJSON(w, code, resp)
|
|
}
|
|
|
|
// notFound answers an unknown path with the frozen error envelope.
|
|
func notFound(w http.ResponseWriter, r *http.Request) {
|
|
writeError(w, http.StatusNotFound, "", "not_found", "Unknown path.")
|
|
}
|
|
|
|
// auth reads the bearer token and returns the client source. It writes the 401
|
|
// reply itself when the token is missing or unknown.
|
|
func (s *server) auth(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
token := bearer(r)
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "", "unauthorized", "A bearer token is required.")
|
|
return "", false
|
|
}
|
|
source, ok := s.cfg.SourceForToken(token)
|
|
if !ok {
|
|
writeError(w, http.StatusUnauthorized, "", "unauthorized", "The bearer token is not valid.")
|
|
return "", false
|
|
}
|
|
return source, true
|
|
}
|
|
|
|
// bearer reads the token from the Authorization header.
|
|
func bearer(r *http.Request) string {
|
|
h := r.Header.Get("Authorization")
|
|
const prefix = "Bearer "
|
|
if len(h) <= len(prefix) || !strings.EqualFold(h[:len(prefix)], prefix) {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(h[len(prefix):])
|
|
}
|
|
|
|
// requireJSON checks the content type and returns false after writing 415.
|
|
func requireJSON(w http.ResponseWriter, r *http.Request) bool {
|
|
ct := r.Header.Get("Content-Type")
|
|
if ct == "" {
|
|
writeError(w, http.StatusUnsupportedMediaType, "", "unsupported_media_type", "Send the body as application/json.")
|
|
return false
|
|
}
|
|
mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0]))
|
|
if mediaType != "application/json" {
|
|
writeError(w, http.StatusUnsupportedMediaType, "", "unsupported_media_type", "Send the body as application/json.")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// readBody reads at most 64 KiB. A larger body gets 413.
|
|
func readBody(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
var tooBig *http.MaxBytesError
|
|
if errors.As(err, &tooBig) {
|
|
writeError(w, http.StatusRequestEntityTooLarge, "", "payload_too_large", "The request body is over 64 KiB.")
|
|
return nil, false
|
|
}
|
|
writeError(w, http.StatusBadRequest, "", "bad_request", "The request body is not valid JSON.")
|
|
return nil, false
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
type errorBody struct {
|
|
Status string `json:"status"`
|
|
Error errorInfo `json:"error"`
|
|
}
|
|
|
|
type errorInfo struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
RequestID string `json:"request_id,omitempty"`
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, code int, requestID, errCode, message string) {
|
|
writeJSON(w, code, errorBody{
|
|
Status: "error",
|
|
Error: errorInfo{Code: errCode, Message: message, RequestID: requestID},
|
|
})
|
|
}
|
|
|
|
// writeAPIError writes an *apierr.Error with the frozen envelope.
|
|
func writeAPIError(w http.ResponseWriter, e *apierr.Error, requestID string) {
|
|
writeError(w, e.HTTP, requestID, e.Code, e.Message)
|
|
}
|
|
|
|
// rateLimiter is a fixed-window cap per client token (docs/hub-api.md section
|
|
// 4). It protects the Hub and is not a security control.
|
|
type rateLimiter struct {
|
|
limit int
|
|
mu sync.Mutex
|
|
win map[string]*rateWindow
|
|
}
|
|
|
|
type rateWindow struct {
|
|
start time.Time
|
|
count int
|
|
}
|
|
|
|
func newRateLimiter(limit int) *rateLimiter {
|
|
if limit <= 0 {
|
|
limit = 30
|
|
}
|
|
return &rateLimiter{limit: limit, win: map[string]*rateWindow{}}
|
|
}
|
|
|
|
// allow reports whether the key may proceed. It returns the seconds to wait
|
|
// when it may not.
|
|
func (rl *rateLimiter) allow(key string) (bool, int) {
|
|
rl.mu.Lock()
|
|
defer rl.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
w, ok := rl.win[key]
|
|
if !ok || now.Sub(w.start) >= time.Minute {
|
|
rl.win[key] = &rateWindow{start: now, count: 1}
|
|
return true, 0
|
|
}
|
|
if w.count >= rl.limit {
|
|
retry := int(time.Until(w.start.Add(time.Minute)).Seconds())
|
|
if retry < 1 {
|
|
retry = 1
|
|
}
|
|
return false, retry
|
|
}
|
|
w.count++
|
|
return true, 0
|
|
}
|
|
|
|
// omniProbe checks OmniRoute for GET /health. It never blocks over 1 second.
|
|
func omniProbe(cfg *config.Config) func(ctx context.Context) map[string]string {
|
|
return func(ctx context.Context) map[string]string {
|
|
if cfg.OmniKey == "" {
|
|
return map[string]string{"omniroute": "unknown"}
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.OmniURL+"/v1/models", nil)
|
|
if err != nil {
|
|
return map[string]string{"omniroute": "down"}
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+cfg.OmniKey)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return map[string]string{"omniroute": "down"}
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode == http.StatusOK {
|
|
return map[string]string{"omniroute": "ok"}
|
|
}
|
|
return map[string]string{"omniroute": "down"}
|
|
}
|
|
}
|
|
|
|
// healthcheck calls this process's own /health. It is the container health
|
|
// probe, because the runtime image has no shell and no curl.
|
|
func healthcheck(cfg *config.Config) {
|
|
url := fmt.Sprintf("http://127.0.0.1:%s/health", cfg.Port)
|
|
client := &http.Client{Timeout: 1 * time.Second}
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
log.Printf("healthcheck failed: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
log.Printf("healthcheck status: %d", resp.StatusCode)
|
|
os.Exit(1)
|
|
}
|
|
os.Exit(0)
|
|
}
|