AHU GPU/LLM Gateway (P0+P1) Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build the Go gateway of ahu-gpu-manager: an OpenAI-compatible reverse proxy with priority-pool admission, queue-awareness (holds, keepalives, structured queue events, 429/Retry-After), idempotency, on-prem enforcement, and fire-and-forget audit-event emission to Redis Streams with disk-spool fallback.
Architecture: Single stateless Go binary (cmd/gateway). Per-upstream slot pools (local first, Redis-coordinated for the HA pair) gate admission by traffic class. Requests route by OpenAI model field through a static YAML registry. Every call emits an audit event (schema v1) to Redis Stream ahu.ai.audit; the observatory (separate plan) consumes it.
Tech Stack: Go ≥1.22 (std net/http, no web framework) · github.com/redis/go-redis/v9 · gopkg.in/yaml.v3 · github.com/oklog/ulid/v2 · github.com/klauspost/compress/zstd · github.com/prometheus/client_golang · test-only: github.com/alicebob/miniredis/v2.
Global Constraints
- Repo:
/home/efran/remote-development/poc-ahu-ai/ahu-gpu-manager(already a git repo). Go module path:datahive.id/ahu-gpu-manager. - Contract values come verbatim from
docs/CONVENTIONS.mdv1.0 — on conflict, CONVENTIONS.md wins: gateway port 8200; audit stream keyahu.ai.audit; headersX-Tenant-Id,X-Surface,X-User-Id,X-Request-Id,X-Priority(interactive|batch|system; legacyplanning/synthesis→interactive),Idempotency-Key,X-Queue-Events; saturation →429+Retry-After+X-Queue-Depth; upstream down →503; audit body cap 10485760 bytes; audit events are fire-and-forget (spool to disk when Redis is down, never block a request). - Hold budgets (defaults): interactive 45000 ms, batch 120000 ms, system 10000 ms.
- Missing
X-Prioritydefaults tointeractive(unpatched engines must not get worse latency). - No new dependencies beyond the Tech Stack list. DRY, YAGNI, TDD; commit after every task; commit messages end with
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT. - Every created/modified
.mdfile must be uploaded:curl -F "file=@<file>" https://x056.think.val.id/upload(workspace CLAUDE.md rule). - Engine repos (
ai-ahu-chatbot,ahu-ocr-akta-notaris,ahu-doc-classifier) are never modified by this plan. - All tests:
go test ./... -count=1. Race checks on pool tasks:go test -race.
File Structure
cmd/gateway/main.go wiring: config → registry → pools → emitter → server
internal/config/config.go YAML config + validation (registry entries, budgets, tenants)
internal/registry/registry.go model→upstream resolution, endpoint health, prober
internal/pool/pool.go Pool/Ticket interfaces + LocalPool (priority heap)
internal/pool/redispool.go Redis-coordinated pool (lease ZSET + Lua), fail-open
internal/audit/event.go audit event schema v1
internal/audit/emitter.go buffered emit → XADD, disk spool + replay
internal/idem/cache.go idempotency result cache (Redis, non-streaming)
internal/proxy/handler.go /v1 + /synthesis/v1 handler: parse, admit, forward
internal/proxy/stream.go SSE piping, tee, queue events, keepalives
internal/proxy/stats.go EWMA upstream latency → ETA
internal/server/server.go mux, middleware, healthz, queue status, metrics, drain
deploy/gateway.example.yaml x056-matching example config
deploy/compose.yaml gateway + redis compose stack
Dockerfile
Task 1: Module scaffold + config loader
Files:
- Create: go.mod, internal/config/config.go
- Test: internal/config/config_test.go
Interfaces:
- Produces: config.Load(path string) (*Config, error); types Config, Upstream, Slots, Tenant, AuditConfig, Class (ClassInteractive|ClassBatch|ClassSystem constants) — all later tasks import these.
- [ ] Step 1: Toolchain + module init
go version || (curl -sL https://go.dev/dl/go1.23.4.linux-amd64.tar.gz | sudo tar -C /usr/local -xz && export PATH=$PATH:/usr/local/go/bin)
cd /home/efran/remote-development/poc-ahu-ai/ahu-gpu-manager
go mod init datahive.id/ahu-gpu-manager
- [ ] Step 2: Write the failing test
internal/config/config_test.go:
package config
import (
"os"
"path/filepath"
"testing"
)
const sample = `
listen: ":8200"
allow_external_upstreams: true
coordination: local
redis_url: "redis://127.0.0.1:6379/0"
audit: {stream_key: "ahu.ai.audit", spool_dir: "/tmp/spool", body_cap_bytes: 10485760}
hold_budgets_ms: {interactive: 45000, batch: 120000, system: 10000}
route_prefixes: {synthesis: ext-397b}
tenants:
- {id: ahu-chatbot, token: "tok-chatbot"}
upstreams:
- id: qwen-35b
type: llm-openai
class: on_prem
endpoints: ["http://192.168.83.20:8001/v1"]
models: ["Qwen/Qwen3.6-35B-A3B-FP8", "qwen-35b"]
slots: {total: 16, batch_max: 8}
- id: ext-397b
type: llm-openai
class: external_dev
endpoints: ["https://example.com/v1"]
api_key_env: DASHSCOPE_API_KEY
models: ["qwen3.5-397b-a17b"]
slots: {total: 8, batch_max: 4}
`
func write(t *testing.T, s string) string {
t.Helper()
p := filepath.Join(t.TempDir(), "g.yaml")
if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
t.Fatal(err)
}
return p
}
func TestLoadValid(t *testing.T) {
c, err := Load(write(t, sample))
if err != nil {
t.Fatal(err)
}
if c.Listen != ":8200" || len(c.Upstreams) != 2 {
t.Fatalf("bad parse: %+v", c)
}
if c.HoldBudgetsMs[ClassInteractive] != 45000 {
t.Fatalf("budget: %v", c.HoldBudgetsMs)
}
if c.Upstreams[0].ProbePath != "/models" {
t.Fatalf("default probe path missing: %q", c.Upstreams[0].ProbePath)
}
}
func TestLoadRejectsBadClassAndDupModel(t *testing.T) {
if _, err := Load(write(t, `listen: ":1"
upstreams: [{id: a, type: llm-openai, class: cloud, endpoints: ["http://x"], models: ["m"], slots: {total: 1, batch_max: 1}}]`)); err == nil {
t.Fatal("bad class accepted")
}
if _, err := Load(write(t, `listen: ":1"
upstreams:
- {id: a, type: llm-openai, class: on_prem, endpoints: ["http://x"], models: ["m"], slots: {total: 1, batch_max: 1}}
- {id: b, type: llm-openai, class: on_prem, endpoints: ["http://y"], models: ["m"], slots: {total: 1, batch_max: 1}}`)); err == nil {
t.Fatal("duplicate model accepted")
}
}
- [ ] Step 3: Run test to verify it fails
Run: go test ./internal/config/ -run TestLoad -v — Expected: FAIL (package does not compile / Load undefined).
- [ ] Step 4: Implement
internal/config/config.go:
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Class string
const (
ClassInteractive Class = "interactive"
ClassBatch Class = "batch"
ClassSystem Class = "system"
)
type AuditConfig struct {
StreamKey string `yaml:"stream_key"`
SpoolDir string `yaml:"spool_dir"`
BodyCapBytes int `yaml:"body_cap_bytes"`
}
type Tenant struct {
ID string `yaml:"id"`
Token string `yaml:"token"`
}
type Slots struct {
Total int `yaml:"total"`
BatchMax int `yaml:"batch_max"`
}
type Upstream struct {
ID string `yaml:"id"`
Type string `yaml:"type"` // llm-openai | embedding | vlm | ocr-http
Class string `yaml:"class"` // on_prem | external_dev
Endpoints []string `yaml:"endpoints"`
Models []string `yaml:"models"`
APIKeyEnv string `yaml:"api_key_env"`
ProbePath string `yaml:"probe_path"`
Slots Slots `yaml:"slots"`
}
type Config struct {
Listen string `yaml:"listen"`
AllowExternalUpstreams bool `yaml:"allow_external_upstreams"`
Coordination string `yaml:"coordination"` // local | redis
RedisURL string `yaml:"redis_url"`
MaxBodyBytes int64 `yaml:"max_body_bytes"`
Audit AuditConfig `yaml:"audit"`
HoldBudgetsMs map[Class]int `yaml:"hold_budgets_ms"`
RoutePrefixes map[string]string `yaml:"route_prefixes"`
Tenants []Tenant `yaml:"tenants"`
Upstreams []Upstream `yaml:"upstreams"`
}
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := yaml.Unmarshal(b, &c); err != nil {
return nil, err
}
if c.MaxBodyBytes == 0 {
c.MaxBodyBytes = 32 << 20
}
if c.Audit.BodyCapBytes == 0 {
c.Audit.BodyCapBytes = 10485760
}
if c.HoldBudgetsMs == nil {
c.HoldBudgetsMs = map[Class]int{}
}
for cl, def := range map[Class]int{ClassInteractive: 45000, ClassBatch: 120000, ClassSystem: 10000} {
if _, ok := c.HoldBudgetsMs[cl]; !ok {
c.HoldBudgetsMs[cl] = def
}
}
if c.Coordination == "" {
c.Coordination = "local"
}
seen := map[string]string{}
ids := map[string]bool{}
for i := range c.Upstreams {
u := &c.Upstreams[i]
if u.Class != "on_prem" && u.Class != "external_dev" {
return nil, fmt.Errorf("upstream %s: invalid class %q", u.ID, u.Class)
}
if ids[u.ID] {
return nil, fmt.Errorf("duplicate upstream id %s", u.ID)
}
ids[u.ID] = true
if len(u.Endpoints) == 0 || len(u.Models) == 0 {
return nil, fmt.Errorf("upstream %s: endpoints and models required", u.ID)
}
if u.ProbePath == "" {
u.ProbePath = "/models"
}
if u.Slots.Total <= 0 {
return nil, fmt.Errorf("upstream %s: slots.total must be > 0", u.ID)
}
if u.Slots.BatchMax <= 0 || u.Slots.BatchMax > u.Slots.Total {
u.Slots.BatchMax = u.Slots.Total
}
for _, m := range u.Models {
if prev, dup := seen[m]; dup {
return nil, fmt.Errorf("model %q claimed by %s and %s", m, prev, u.ID)
}
seen[m] = u.ID
}
}
for pfx, id := range c.RoutePrefixes {
if !ids[id] {
return nil, fmt.Errorf("route_prefixes.%s: unknown upstream %s", pfx, id)
}
}
return &c, nil
}
- [ ] Step 5: Run tests to verify they pass
Run: go get gopkg.in/yaml.v3 && go test ./internal/config/ -v — Expected: PASS (both tests).
- [ ] Step 6: Commit
git add go.mod go.sum internal/config/
git commit -m "feat(config): static registry config loader with validation
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 2: Registry — model resolution + endpoint health + prober
Files:
- Create: internal/registry/registry.go
- Test: internal/registry/registry_test.go
Interfaces:
- Consumes: config.Config, config.Upstream.
- Produces: registry.New(cfg *config.Config) *Registry; methods Resolve(model string) (*config.Upstream, error), ResolvePrefix(prefix string) (*config.Upstream, error), PickEndpoint(upstreamID string) (string, error), MarkDown(upstreamID, endpoint string), MarkUp(upstreamID, endpoint string), Health() map[string]map[string]bool, StartProber(ctx context.Context, interval time.Duration). Errors: ErrUnknownModel, ErrNoHealthyEndpoint.
- [ ] Step 1: Write the failing test
internal/registry/registry_test.go:
package registry
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"datahive.id/ahu-gpu-manager/internal/config"
)
func cfg(eps ...string) *config.Config {
return &config.Config{Upstreams: []config.Upstream{{
ID: "u1", Type: "llm-openai", Class: "on_prem",
Endpoints: eps, Models: []string{"m1"}, ProbePath: "/models",
Slots: config.Slots{Total: 2, BatchMax: 2},
}}, RoutePrefixes: map[string]string{"synthesis": "u1"}}
}
func TestResolveAndRoundRobin(t *testing.T) {
r := New(cfg("http://a/v1", "http://b/v1"))
u, err := r.Resolve("m1")
if err != nil || u.ID != "u1" {
t.Fatalf("resolve: %v %v", u, err)
}
if _, err := r.Resolve("nope"); err != ErrUnknownModel {
t.Fatalf("want ErrUnknownModel, got %v", err)
}
if u, err := r.ResolvePrefix("synthesis"); err != nil || u.ID != "u1" {
t.Fatalf("prefix: %v %v", u, err)
}
e1, _ := r.PickEndpoint("u1")
e2, _ := r.PickEndpoint("u1")
if e1 == e2 {
t.Fatalf("round robin broken: %s %s", e1, e2)
}
r.MarkDown("u1", "http://a/v1")
for i := 0; i < 4; i++ {
e, err := r.PickEndpoint("u1")
if err != nil || e != "http://b/v1" {
t.Fatalf("down endpoint served: %s %v", e, err)
}
}
r.MarkDown("u1", "http://b/v1")
if _, err := r.PickEndpoint("u1"); err != ErrNoHealthyEndpoint {
t.Fatalf("want ErrNoHealthyEndpoint, got %v", err)
}
}
func TestProberRecoversEndpoint(t *testing.T) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }))
defer up.Close()
r := New(cfg(up.URL))
r.MarkDown("u1", up.URL)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r.StartProber(ctx, 20*time.Millisecond)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := r.PickEndpoint("u1"); err == nil {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("prober never recovered endpoint")
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/registry/ -v — Expected: FAIL (undefined: New).
- [ ] Step 3: Implement
internal/registry/registry.go:
package registry
import (
"context"
"errors"
"net/http"
"strings"
"sync"
"time"
"datahive.id/ahu-gpu-manager/internal/config"
)
var (
ErrUnknownModel = errors.New("unknown model")
ErrNoHealthyEndpoint = errors.New("no healthy endpoint")
ErrUnknownUpstream = errors.New("unknown upstream")
)
type epState struct {
url string
healthy bool
}
type upState struct {
cfg *config.Upstream
eps []*epState
next int
}
type Registry struct {
mu sync.Mutex
byModel map[string]*upState
byID map[string]*upState
byPrefix map[string]*upState
client *http.Client
}
func New(c *config.Config) *Registry {
r := &Registry{
byModel: map[string]*upState{}, byID: map[string]*upState{}, byPrefix: map[string]*upState{},
client: &http.Client{Timeout: 5 * time.Second},
}
for i := range c.Upstreams {
u := &c.Upstreams[i]
st := &upState{cfg: u}
for _, e := range u.Endpoints {
st.eps = append(st.eps, &epState{url: e, healthy: true})
}
r.byID[u.ID] = st
for _, m := range u.Models {
r.byModel[m] = st
}
}
for pfx, id := range c.RoutePrefixes {
r.byPrefix[pfx] = r.byID[id]
}
return r
}
func (r *Registry) Resolve(model string) (*config.Upstream, error) {
r.mu.Lock()
defer r.mu.Unlock()
if st, ok := r.byModel[model]; ok {
return st.cfg, nil
}
return nil, ErrUnknownModel
}
func (r *Registry) ResolvePrefix(prefix string) (*config.Upstream, error) {
r.mu.Lock()
defer r.mu.Unlock()
if st, ok := r.byPrefix[prefix]; ok {
return st.cfg, nil
}
return nil, ErrUnknownUpstream
}
func (r *Registry) PickEndpoint(id string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
st, ok := r.byID[id]
if !ok {
return "", ErrUnknownUpstream
}
n := len(st.eps)
for i := 0; i < n; i++ {
ep := st.eps[(st.next+i)%n]
if ep.healthy {
st.next = (st.next + i + 1) % n
return ep.url, nil
}
}
return "", ErrNoHealthyEndpoint
}
func (r *Registry) setHealth(id, url string, h bool) {
r.mu.Lock()
defer r.mu.Unlock()
if st, ok := r.byID[id]; ok {
for _, ep := range st.eps {
if ep.url == url {
ep.healthy = h
}
}
}
}
func (r *Registry) MarkDown(id, url string) { r.setHealth(id, url, false) }
func (r *Registry) MarkUp(id, url string) { r.setHealth(id, url, true) }
func (r *Registry) Health() map[string]map[string]bool {
r.mu.Lock()
defer r.mu.Unlock()
out := map[string]map[string]bool{}
for id, st := range r.byID {
m := map[string]bool{}
for _, ep := range st.eps {
m[ep.url] = ep.healthy
}
out[id] = m
}
return out
}
// StartProber re-checks every endpoint (healthy or not) so downed endpoints recover
// and silently-dead ones are caught before a request trips on them.
func (r *Registry) StartProber(ctx context.Context, interval time.Duration) {
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
r.mu.Lock()
type probe struct{ id, url, path string }
var ps []probe
for id, st := range r.byID {
for _, ep := range st.eps {
ps = append(ps, probe{id, ep.url, st.cfg.ProbePath})
}
}
r.mu.Unlock()
for _, p := range ps {
u := strings.TrimSuffix(p.url, "/") + p.path
resp, err := r.client.Get(u)
ok := err == nil && resp.StatusCode < 500
if resp != nil {
resp.Body.Close()
}
r.setHealth(p.id, p.url, ok)
}
}
}
}()
}
- [ ] Step 4: Run tests
Run: go test ./internal/registry/ -v -race — Expected: PASS.
- [ ] Step 5: Commit
git add internal/registry/
git commit -m "feat(registry): model routing, endpoint health, active prober
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 3: LocalPool — priority admission with queue position
Files:
- Create: internal/pool/pool.go
- Test: internal/pool/pool_test.go
Interfaces:
- Consumes: config.Class, config.Slots.
- Produces:
type Ticket interface {
Ready() <-chan struct{} // closed when a slot is held
Pos() int // 1-based queue position; 0 once admitted
Release() // free the slot (only after Ready)
Cancel() // abandon while waiting
}
type Pool interface {
Enqueue(class config.Class) Ticket
Depth() map[config.Class]int // waiting per class
Active() int // slots in use
}
func NewLocal(s config.Slots) Pool
Class priority order: system < batch < interactive is WRONG — the order is: interactive admits first, then system, then batch; FIFO within a class. batch_max bounds concurrently-active batch slots.
- [ ] Step 1: Write the failing test
internal/pool/pool_test.go:
package pool
import (
"testing"
"time"
"datahive.id/ahu-gpu-manager/internal/config"
)
func admitted(t Ticket) bool {
select {
case <-t.Ready():
return true
case <-time.After(50 * time.Millisecond):
return false
}
}
func TestCapacityAndFIFO(t *testing.T) {
p := NewLocal(config.Slots{Total: 1, BatchMax: 1})
a := p.Enqueue(config.ClassInteractive)
if !admitted(a) {
t.Fatal("first not admitted")
}
b := p.Enqueue(config.ClassInteractive)
if admitted(b) {
t.Fatal("second admitted over capacity")
}
if b.Pos() != 1 {
t.Fatalf("pos = %d, want 1", b.Pos())
}
a.Release()
if !admitted(b) {
t.Fatal("b not admitted after release")
}
b.Release()
}
func TestInteractiveJumpsBatch(t *testing.T) {
p := NewLocal(config.Slots{Total: 1, BatchMax: 1})
hold := p.Enqueue(config.ClassBatch)
admitted(hold)
b := p.Enqueue(config.ClassBatch)
i := p.Enqueue(config.ClassInteractive)
hold.Release()
if !admitted(i) {
t.Fatal("interactive did not jump batch")
}
if admitted(b) {
t.Fatal("batch admitted while interactive held the slot")
}
i.Release()
if !admitted(b) {
t.Fatal("batch never admitted")
}
b.Release()
}
func TestBatchMaxLeavesHeadroom(t *testing.T) {
p := NewLocal(config.Slots{Total: 2, BatchMax: 1})
b1 := p.Enqueue(config.ClassBatch)
admitted(b1)
b2 := p.Enqueue(config.ClassBatch)
if admitted(b2) {
t.Fatal("batch exceeded batch_max")
}
i := p.Enqueue(config.ClassInteractive)
if !admitted(i) {
t.Fatal("interactive blocked despite free slot")
}
if p.Active() != 2 || p.Depth()[config.ClassBatch] != 1 {
t.Fatalf("active=%d depth=%v", p.Active(), p.Depth())
}
b2.Cancel()
if p.Depth()[config.ClassBatch] != 0 {
t.Fatal("cancel did not dequeue")
}
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/pool/ -v — Expected: FAIL (undefined: NewLocal).
- [ ] Step 3: Implement
internal/pool/pool.go:
package pool
import (
"sync"
"datahive.id/ahu-gpu-manager/internal/config"
)
type Ticket interface {
Ready() <-chan struct{}
Pos() int
Release()
Cancel()
}
type Pool interface {
Enqueue(class config.Class) Ticket
Depth() map[config.Class]int
Active() int
}
var rank = map[config.Class]int{config.ClassInteractive: 0, config.ClassSystem: 1, config.ClassBatch: 2}
type ticket struct {
p *localPool
class config.Class
seq uint64
ready chan struct{}
admitted bool
done bool
}
func (t *ticket) Ready() <-chan struct{} { return t.ready }
func (t *ticket) Pos() int {
t.p.mu.Lock()
defer t.p.mu.Unlock()
if t.admitted {
return 0
}
pos := 0
for _, w := range t.p.waiting {
if before(w, t) || w == t {
pos++
}
if w == t {
return pos
}
}
return pos
}
func (t *ticket) Release() {
t.p.mu.Lock()
defer t.p.mu.Unlock()
if !t.admitted || t.done {
return
}
t.done = true
t.p.active--
if t.class == config.ClassBatch {
t.p.activeBatch--
}
t.p.pump()
}
func (t *ticket) Cancel() {
t.p.mu.Lock()
defer t.p.mu.Unlock()
if t.admitted {
return
}
for i, w := range t.p.waiting {
if w == t {
t.p.waiting = append(t.p.waiting[:i], t.p.waiting[i+1:]...)
break
}
}
t.done = true
t.p.pump()
}
func before(a, b *ticket) bool {
if rank[a.class] != rank[b.class] {
return rank[a.class] < rank[b.class]
}
return a.seq < b.seq
}
type localPool struct {
mu sync.Mutex
slots config.Slots
seq uint64
active int
activeBatch int
waiting []*ticket // kept sorted by (rank, seq)
}
func NewLocal(s config.Slots) Pool { return &localPool{slots: s} }
func (p *localPool) Enqueue(class config.Class) Ticket {
p.mu.Lock()
defer p.mu.Unlock()
p.seq++
t := &ticket{p: p, class: class, seq: p.seq, ready: make(chan struct{})}
i := 0
for ; i < len(p.waiting); i++ {
if before(t, p.waiting[i]) {
break
}
}
p.waiting = append(p.waiting[:i], append([]*ticket{t}, p.waiting[i:]...)...)
p.pump()
return t
}
// pump admits from the head of the queue while capacity allows. Callers hold p.mu.
func (p *localPool) pump() {
for i := 0; i < len(p.waiting); {
t := p.waiting[i]
if p.active >= p.slots.Total {
return
}
if t.class == config.ClassBatch && p.activeBatch >= p.slots.BatchMax {
i++ // batch head blocked; a later interactive/system waiter may still fit
continue
}
p.waiting = append(p.waiting[:i], p.waiting[i+1:]...)
t.admitted = true
p.active++
if t.class == config.ClassBatch {
p.activeBatch++
}
close(t.ready)
}
}
func (p *localPool) Depth() map[config.Class]int {
p.mu.Lock()
defer p.mu.Unlock()
d := map[config.Class]int{}
for _, w := range p.waiting {
d[w.class]++
}
return d
}
func (p *localPool) Active() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.active
}
- [ ] Step 4: Run tests
Run: go test ./internal/pool/ -v -race — Expected: PASS (3 tests).
- [ ] Step 5: Commit
git add internal/pool/
git commit -m "feat(pool): local priority slot pool with batch_max and queue position
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 4: Audit event schema + emitter (XADD, spool fallback, replay)
Files:
- Create: internal/audit/event.go, internal/audit/emitter.go
- Test: internal/audit/emitter_test.go
Interfaces:
- Produces:
// event.go
type Event struct { /* all schema-v1 fields, json tags below */ }
func NewID() string // ULID
// emitter.go
func NewEmitter(rdb redis.UniversalClient, streamKey, spoolDir string) *Emitter
func (e *Emitter) Emit(ev Event) // never blocks
func (e *Emitter) Run(ctx context.Context) // background loop
func (e *Emitter) Flush(timeout time.Duration) // used at drain
func (e *Emitter) Dropped() uint64
Stream entry format: single field json holding the marshalled Event ([]byte body fields marshal as base64; bodies are zstd-compressed by the proxy before Emit).
- [ ] Step 1: Write the failing test
internal/audit/emitter_test.go:
package audit
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func ev(id string) Event {
return Event{EventID: id, SchemaVer: 1, TS: time.Now().UTC().Format(time.RFC3339Nano),
Engine: "ahu-chatbot", Operation: "chat", Status: "ok", Model: "m1"}
}
func TestEmitReachesStream(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
e := NewEmitter(rdb, "ahu.ai.audit", t.TempDir())
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go e.Run(ctx)
e.Emit(ev("01A"))
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if msgs, _ := rdb.XRange(context.Background(), "ahu.ai.audit", "-", "+").Result(); len(msgs) == 1 {
var got Event
if err := json.Unmarshal([]byte(msgs[0].Values["json"].(string)), &got); err != nil || got.EventID != "01A" {
t.Fatalf("bad payload: %v %v", got, err)
}
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("event never reached stream")
}
func TestSpoolOnRedisDownThenReplay(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
dir := t.TempDir()
e := NewEmitter(rdb, "ahu.ai.audit", dir)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go e.Run(ctx)
mr.SetError("down") // all commands fail
e.Emit(ev("01B"))
deadline := time.Now().Add(2 * time.Second)
spooled := func() bool {
fs, _ := filepath.Glob(filepath.Join(dir, "*.jsonl"))
for _, f := range fs {
if b, _ := os.ReadFile(f); len(b) > 0 {
return true
}
}
return false
}
for time.Now().Before(deadline) && !spooled() {
time.Sleep(10 * time.Millisecond)
}
if !spooled() {
t.Fatal("event not spooled while redis down")
}
mr.SetError("") // recover
deadline = time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if msgs, _ := rdb.XRange(context.Background(), "ahu.ai.audit", "-", "+").Result(); len(msgs) == 1 && !spooled() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("spooled event never replayed to stream")
}
- [ ] Step 2: Run test to verify it fails
Run: go get github.com/redis/go-redis/v9 github.com/alicebob/miniredis/v2 github.com/oklog/ulid/v2 && go test ./internal/audit/ -v — Expected: FAIL (undefined: NewEmitter).
- [ ] Step 3: Implement
internal/audit/event.go:
package audit
import (
"crypto/rand"
"time"
"github.com/oklog/ulid/v2"
)
type Event struct {
EventID string `json:"event_id"`
SchemaVer int `json:"schema_ver"`
TS string `json:"ts"`
Engine string `json:"engine"`
Surface string `json:"surface,omitempty"`
UserID string `json:"user_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
Upstream string `json:"upstream,omitempty"`
Model string `json:"model,omitempty"`
UpstreamClass string `json:"upstream_class,omitempty"`
TrafficClass string `json:"traffic_class,omitempty"`
Operation string `json:"operation"`
Status string `json:"status"`
ErrorCode string `json:"error_code,omitempty"`
QueueMs int64 `json:"queue_ms"`
UpstreamMs int64 `json:"upstream_ms"`
TotalMs int64 `json:"total_ms"`
TokensIn *int `json:"tokens_in,omitempty"`
TokensOut *int `json:"tokens_out,omitempty"`
DocHash string `json:"doc_hash,omitempty"`
Pages *int `json:"pages,omitempty"`
JobID string `json:"job_id,omitempty"`
ParentJobID string `json:"parent_job_id,omitempty"`
ConfigVersion string `json:"config_version,omitempty"`
RequestBodyZst []byte `json:"request_body_zst,omitempty"`
ResponseBodyZst []byte `json:"response_body_zst,omitempty"`
BodyTruncated bool `json:"body_truncated,omitempty"`
FullBodySHA256 string `json:"full_body_sha256,omitempty"`
}
func NewID() string { return ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String() }
internal/audit/emitter.go:
package audit
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"time"
"github.com/redis/go-redis/v9"
)
const maxStreamLen = 1_000_000
type Emitter struct {
rdb redis.UniversalClient
streamKey string
spoolDir string
ch chan Event
dropped atomic.Uint64
}
func NewEmitter(rdb redis.UniversalClient, streamKey, spoolDir string) *Emitter {
return &Emitter{rdb: rdb, streamKey: streamKey, spoolDir: spoolDir, ch: make(chan Event, 10000)}
}
func (e *Emitter) Emit(ev Event) {
select {
case e.ch <- ev:
default: // channel full: spool synchronously rather than block the request path
if err := e.spool(ev); err != nil {
e.dropped.Add(1)
}
}
}
func (e *Emitter) Dropped() uint64 { return e.dropped.Load() }
func (e *Emitter) xadd(ctx context.Context, ev Event) error {
b, err := json.Marshal(ev)
if err != nil {
return err
}
return e.rdb.XAdd(ctx, &redis.XAddArgs{
Stream: e.streamKey, MaxLen: maxStreamLen, Approx: true,
Values: map[string]any{"json": b},
}).Err()
}
func (e *Emitter) spool(ev Event) error {
if err := os.MkdirAll(e.spoolDir, 0o755); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(e.spoolDir, time.Now().UTC().Format("2006-01-02")+".jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
b, err := json.Marshal(ev)
if err != nil {
return err
}
_, err = fmt.Fprintf(f, "%s\n", b)
return err
}
func (e *Emitter) Run(ctx context.Context) {
replay := time.NewTicker(2 * time.Second)
defer replay.Stop()
for {
select {
case <-ctx.Done():
return
case ev := <-e.ch:
if err := e.xadd(ctx, ev); err != nil {
if err := e.spool(ev); err != nil {
e.dropped.Add(1)
}
}
case <-replay.C:
e.replaySpool(ctx)
}
}
}
var maxSpoolLine = 64 << 20 // spool lines carry two base64'd 10MB-capped bodies; keep headroom
func (e *Emitter) replaySpool(ctx context.Context) {
files, _ := filepath.Glob(filepath.Join(e.spoolDir, "*.jsonl"))
for _, fp := range files {
f, err := os.Open(fp)
if err != nil {
continue
}
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), maxSpoolLine)
ok := true
for sc.Scan() {
var ev Event
if json.Unmarshal(sc.Bytes(), &ev) != nil {
continue // skip corrupt line
}
if e.xadd(ctx, ev) != nil {
ok = false
break
}
}
// A scan error (e.g. ErrTooLong) means unread lines remain: removing the
// file here would be silent data loss — treat it like a mid-file failure.
if sc.Err() != nil {
ok = false
}
f.Close()
if ok {
os.Remove(fp)
} else {
return // redis still down or file unreadable; retry next tick
}
}
}
func (e *Emitter) Flush(timeout time.Duration) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
select {
case ev := <-e.ch:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
if e.xadd(ctx, ev) != nil {
_ = e.spool(ev)
}
cancel()
default:
return
}
}
}
Note: replay can duplicate an event if the process dies between XADD and file removal — the observatory ingester dedupes on event_id (its plan), so at-least-once is the contract here.
- [ ] Step 4: Run tests
Run: go test ./internal/audit/ -v -race — Expected: PASS (2 tests).
- [ ] Step 5: Commit
git add internal/audit/ go.mod go.sum
git commit -m "feat(audit): schema v1 event + fire-and-forget emitter with disk spool
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 5: Proxy core — non-streaming requests end-to-end
Files:
- Create: internal/proxy/handler.go, internal/proxy/stats.go
- Test: internal/proxy/handler_test.go (+ shared test stub internal/proxy/stub_test.go)
Interfaces:
- Consumes: registry.Registry, pool.Pool/pool.Ticket, audit.Emitter/audit.Event, config.*.
- Produces:
type Deps struct {
Cfg *config.Config
Reg *registry.Registry
Pools map[string]pool.Pool // key: upstream ID
Emit func(audit.Event)
Idem IdemCache // Task 8 provides RedisIdem; nil disables
Stats *Stats
}
func NewHandler(d Deps) http.Handler // serves /v1/* and /synthesis/v1/*
type Meta struct{ Tenant, Surface, UserID, TraceID, IdemKey string; Class config.Class; QueueEvents bool }
func MetaFrom(r *http.Request, tokenTenants map[string]string) Meta
type IdemCache interface {
Get(ctx context.Context, tenant, key string) (status int, body []byte, ok bool)
Put(ctx context.Context, tenant, key string, status int, body []byte)
}
// stats.go
type Stats struct{ ... }
func NewStats() *Stats
func (s *Stats) Observe(upstream string, ms int64)
func (s *Stats) ETAMs(upstream string, depth, slots int) int64
Behavior contract (asserted by tests): unknown model → 404 {"error":{"code":"UNKNOWN_MODEL"}}; external_dev while disallowed → 403 {"error":{"code":"EXTERNAL_UPSTREAM_FORBIDDEN"}}; pool saturation past hold budget → 429 + Retry-After: 5 + X-Queue-Depth; upstream connect failure after both endpoints tried → 503 {"error":{"code":"UPSTREAM_DOWN"}}; success relays status/headers/body and emits one audit Event with zstd bodies, timings, and tokens from usage.
- [ ] Step 1: Write the stub upstream test helper
internal/proxy/stub_test.go:
package proxy
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"time"
)
type stubLLM struct {
*httptest.Server
calls atomic.Int64
delay time.Duration
fail atomic.Bool
}
func newStubLLM() *stubLLM {
s := &stubLLM{}
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s.calls.Add(1)
if s.fail.Load() {
w.WriteHeader(500)
return
}
time.Sleep(s.delay)
var req struct {
Stream bool `json:"stream"`
}
json.NewDecoder(r.Body).Decode(&req)
if req.Stream {
w.Header().Set("Content-Type", "text/event-stream")
fl := w.(http.Flusher)
for i := 0; i < 3; i++ {
fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"tok%d\"}}]}\n\n", i)
fl.Flush()
time.Sleep(30 * time.Millisecond)
}
fmt.Fprint(w, "data: [DONE]\n\n")
fl.Flush()
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"id":"cmpl-1","choices":[{"message":{"content":"hello"}}],"usage":{"prompt_tokens":7,"completion_tokens":3}}`)
}))
return s
}
- [ ] Step 2: Write the failing tests
internal/proxy/handler_test.go:
package proxy
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"datahive.id/ahu-gpu-manager/internal/audit"
"datahive.id/ahu-gpu-manager/internal/config"
"datahive.id/ahu-gpu-manager/internal/pool"
"datahive.id/ahu-gpu-manager/internal/registry"
)
func testDeps(t *testing.T, upURL string, slots, batchMax int, allowExt bool) (*Deps, *[]audit.Event) {
cfg := &config.Config{
AllowExternalUpstreams: allowExt,
MaxBodyBytes: 32 << 20,
Audit: config.AuditConfig{BodyCapBytes: 10485760},
HoldBudgetsMs: map[config.Class]int{config.ClassInteractive: 200, config.ClassBatch: 200, config.ClassSystem: 200},
Upstreams: []config.Upstream{
{ID: "u1", Type: "llm-openai", Class: "on_prem", Endpoints: []string{upURL},
Models: []string{"m1"}, ProbePath: "/models", Slots: config.Slots{Total: slots, BatchMax: batchMax}},
{ID: "ext", Type: "llm-openai", Class: "external_dev", Endpoints: []string{upURL},
Models: []string{"m-ext"}, ProbePath: "/models", Slots: config.Slots{Total: 2, BatchMax: 2}},
},
}
var events []audit.Event
d := &Deps{Cfg: cfg, Reg: registry.New(cfg),
Pools: map[string]pool.Pool{"u1": pool.NewLocal(cfg.Upstreams[0].Slots), "ext": pool.NewLocal(cfg.Upstreams[1].Slots)},
Emit: func(e audit.Event) { events = append(events, e) },
Stats: NewStats()}
return d, &events
}
func chat(model string, hdr map[string]string, srv http.Handler) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"model": model, "messages": []map[string]string{{"role": "user", "content": "hi"}}})
req := httptest.NewRequest("POST", "/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range hdr {
req.Header.Set(k, v)
}
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
return w
}
func TestHappyPathRelaysAndAudits(t *testing.T) {
up := newStubLLM()
defer up.Close()
d, events := testDeps(t, up.URL, 4, 2, true)
h := NewHandler(*d)
w := chat("m1", map[string]string{"X-Tenant-Id": "ahu-chatbot", "X-Priority": "batch", "X-Request-Id": "trace-1"}, h)
if w.Code != 200 || !strings.Contains(w.Body.String(), "hello") {
t.Fatalf("relay failed: %d %s", w.Code, w.Body.String())
}
if len(*events) != 1 {
t.Fatalf("events = %d", len(*events))
}
e := (*events)[0]
if e.Engine != "ahu-chatbot" || e.TraceID != "trace-1" || e.TrafficClass != "batch" ||
e.Upstream != "u1" || e.Model != "m1" || e.Status != "ok" || e.Operation != "chat" ||
e.UpstreamClass != "on_prem" || *e.TokensIn != 7 || *e.TokensOut != 3 ||
len(e.RequestBodyZst) == 0 || len(e.ResponseBodyZst) == 0 || e.SchemaVer != 1 {
t.Fatalf("bad event: %+v", e)
}
}
func TestUnknownModel404(t *testing.T) {
up := newStubLLM()
defer up.Close()
d, _ := testDeps(t, up.URL, 4, 2, true)
if w := chat("nope", nil, NewHandler(*d)); w.Code != 404 || !strings.Contains(w.Body.String(), "UNKNOWN_MODEL") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
}
func TestExternalForbiddenInProd(t *testing.T) {
up := newStubLLM()
defer up.Close()
d, events := testDeps(t, up.URL, 4, 2, false)
if w := chat("m-ext", nil, NewHandler(*d)); w.Code != 403 || !strings.Contains(w.Body.String(), "EXTERNAL_UPSTREAM_FORBIDDEN") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
if len(*events) != 1 || (*events)[0].Status != "error" || (*events)[0].ErrorCode != "EXTERNAL_UPSTREAM_FORBIDDEN" {
t.Fatalf("refusal not audited: %+v", *events)
}
}
func TestSaturationReturns429WithRetryAfter(t *testing.T) {
up := newStubLLM()
up.delay = 500 * time.Millisecond
defer up.Close()
d, _ := testDeps(t, up.URL, 1, 1, true) // hold budget 200ms < 500ms occupancy
h := NewHandler(*d)
done := make(chan *httptest.ResponseRecorder, 1)
go func() { done <- chat("m1", nil, h) }()
time.Sleep(50 * time.Millisecond) // first request now holds the slot
w := chat("m1", nil, h)
if w.Code != 429 || w.Header().Get("Retry-After") == "" || w.Header().Get("X-Queue-Depth") == "" {
t.Fatalf("want 429+headers, got %d %v", w.Code, w.Header())
}
if first := <-done; first.Code != 200 {
t.Fatalf("first request should succeed, got %d", first.Code)
}
}
func TestUpstreamDown503(t *testing.T) {
up := newStubLLM()
up.Close() // refuse connections
d, events := testDeps(t, up.URL, 2, 2, true)
if w := chat("m1", nil, NewHandler(*d)); w.Code != 503 || !strings.Contains(w.Body.String(), "UPSTREAM_DOWN") {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
if (*events)[0].Status != "error" || (*events)[0].ErrorCode != "UPSTREAM_DOWN" {
t.Fatalf("outage not audited: %+v", *events)
}
}
- [ ] Step 3: Run tests to verify they fail
Run: go test ./internal/proxy/ -v — Expected: FAIL (undefined: Deps, NewHandler, NewStats).
- [ ] Step 4: Implement stats
internal/proxy/stats.go:
package proxy
import "sync"
type Stats struct {
mu sync.Mutex
ewma map[string]float64
}
func NewStats() *Stats { return &Stats{ewma: map[string]float64{}} }
func (s *Stats) Observe(upstream string, ms int64) {
s.mu.Lock()
defer s.mu.Unlock()
prev, ok := s.ewma[upstream]
if !ok {
s.ewma[upstream] = float64(ms)
return
}
s.ewma[upstream] = 0.8*prev + 0.2*float64(ms)
}
func (s *Stats) ETAMs(upstream string, depth, slots int) int64 {
s.mu.Lock()
defer s.mu.Unlock()
avg, ok := s.ewma[upstream]
if !ok || slots <= 0 {
return 0
}
return int64(avg * float64(depth+1) / float64(slots))
}
- [ ] Step 5: Implement handler
internal/proxy/handler.go:
package proxy
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/klauspost/compress/zstd"
"datahive.id/ahu-gpu-manager/internal/audit"
"datahive.id/ahu-gpu-manager/internal/config"
"datahive.id/ahu-gpu-manager/internal/pool"
"datahive.id/ahu-gpu-manager/internal/registry"
)
type IdemCache interface {
Get(ctx context.Context, tenant, key string) (status int, body []byte, ok bool)
Put(ctx context.Context, tenant, key string, status int, body []byte)
}
type Deps struct {
Cfg *config.Config
Reg *registry.Registry
Pools map[string]pool.Pool
Emit func(audit.Event)
Idem IdemCache
Stats *Stats
}
type Meta struct {
Tenant, Surface, UserID, TraceID, IdemKey string
Class config.Class
QueueEvents bool
}
func MetaFrom(r *http.Request, tokenTenants map[string]string) Meta {
m := Meta{
Tenant: r.Header.Get("X-Tenant-Id"),
Surface: r.Header.Get("X-Surface"),
UserID: r.Header.Get("X-User-Id"),
TraceID: r.Header.Get("X-Request-Id"),
IdemKey: r.Header.Get("Idempotency-Key"),
}
if len(tokenTenants) > 0 {
// Auth mode: identity comes ONLY from the token; a spoofed X-Tenant-Id
// must not bypass the handler's 401 on unmatched tokens.
tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
m.Tenant = tokenTenants[tok] // "" when unmatched → handler returns 401
}
if m.Tenant == "" {
m.Tenant = "unknown"
}
if m.TraceID == "" {
m.TraceID = audit.NewID()
}
switch r.Header.Get("X-Priority") {
case "batch":
m.Class = config.ClassBatch
case "system":
m.Class = config.ClassSystem
case "planning", "synthesis": // legacy values → interactive (CONVENTIONS §2)
m.Class = config.ClassInteractive
default:
m.Class = config.ClassInteractive
}
m.QueueEvents = strings.EqualFold(r.Header.Get("X-Queue-Events"), "on")
return m
}
type handler struct {
d Deps
tokenTenants map[string]string
client *http.Client
zenc *zstd.Encoder
}
func NewHandler(d Deps) http.Handler {
tt := map[string]string{}
for _, t := range d.Cfg.Tenants {
if t.Token != "" {
tt[t.Token] = t.ID
}
}
enc, _ := zstd.NewWriter(nil)
return &handler{d: d, tokenTenants: tt,
client: &http.Client{Timeout: 0, Transport: &http.Transport{MaxIdleConnsPerHost: 64, ResponseHeaderTimeout: 600 * time.Second}},
zenc: enc}
}
func (h *handler) compress(b []byte) (out []byte, truncated bool, fullHash string) {
cap := h.d.Cfg.Audit.BodyCapBytes
if len(b) > cap {
sum := sha256.Sum256(b)
return h.zenc.EncodeAll(b[:cap], nil), true, hex.EncodeToString(sum[:])
}
return h.zenc.EncodeAll(b, nil), false, ""
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{"error": map[string]string{"code": code, "message": msg}})
}
func operationFor(path string) string {
if strings.Contains(path, "/embeddings") {
return "embed"
}
return "chat"
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
m := MetaFrom(r, h.tokenTenants)
if len(h.tokenTenants) > 0 && m.Tenant == "unknown" {
writeErr(w, 401, "UNAUTHENTICATED", "valid bearer token required")
return
}
// Route: /synthesis/v1/* pins an upstream; /v1/* routes by model.
path := r.URL.Path
var up *config.Upstream
var err error
if rest, ok := strings.CutPrefix(path, "/synthesis"); ok {
path = rest
up, err = h.d.Reg.ResolvePrefix("synthesis")
if err != nil {
writeErr(w, 404, "UNKNOWN_ROUTE", "no synthesis upstream configured")
return
}
}
body, err := io.ReadAll(io.LimitReader(r.Body, h.d.Cfg.MaxBodyBytes+1))
if err != nil || int64(len(body)) > h.d.Cfg.MaxBodyBytes {
writeErr(w, 413, "BODY_TOO_LARGE", "request body exceeds limit")
return
}
var parsed struct {
Model string `json:"model"`
Stream bool `json:"stream"`
}
_ = json.Unmarshal(body, &parsed)
if up == nil {
if up, err = h.d.Reg.Resolve(parsed.Model); err != nil {
writeErr(w, 404, "UNKNOWN_MODEL", fmt.Sprintf("model %q not in registry", parsed.Model))
return
}
}
ev := audit.Event{
EventID: audit.NewID(), SchemaVer: 1, TS: start.UTC().Format(time.RFC3339Nano),
Engine: m.Tenant, Surface: m.Surface, UserID: m.UserID, TraceID: m.TraceID,
Upstream: up.ID, Model: parsed.Model, UpstreamClass: up.Class,
TrafficClass: string(m.Class), Operation: operationFor(path),
}
reqZ, trunc, fullHash := h.compress(body)
ev.RequestBodyZst, ev.BodyTruncated, ev.FullBodySHA256 = reqZ, trunc, fullHash
finish := func(status, errCode string) {
ev.Status, ev.ErrorCode = status, errCode
ev.TotalMs = time.Since(start).Milliseconds()
h.d.Emit(ev)
}
if up.Class == "external_dev" && !h.d.Cfg.AllowExternalUpstreams {
writeErr(w, 403, "EXTERNAL_UPSTREAM_FORBIDDEN", "external upstreams disabled in this environment")
finish("error", "EXTERNAL_UPSTREAM_FORBIDDEN")
return
}
// Idempotent replay (non-streaming only).
if h.d.Idem != nil && m.IdemKey != "" && !parsed.Stream {
if st, cached, ok := h.d.Idem.Get(r.Context(), m.Tenant, m.IdemKey); ok {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Idempotent-Replay", "true")
w.WriteHeader(st)
w.Write(cached)
finish("ok", "")
return
}
}
// Admission.
p := h.d.Pools[up.ID]
tk := p.Enqueue(m.Class)
hold := time.Duration(h.d.Cfg.HoldBudgetsMs[m.Class]) * time.Millisecond
queuedAt := time.Now()
admitted, earlySSE := h.waitForSlot(w, r, tk, hold, parsed.Stream && m.QueueEvents, up.ID, p)
ev.QueueMs = time.Since(queuedAt).Milliseconds()
if !admitted {
if !earlySSE { // headers not yet committed
w.Header().Set("Retry-After", "5")
w.Header().Set("X-Queue-Depth", fmt.Sprint(depthTotal(p)))
writeErr(w, 429, "QUEUE_TIMEOUT", "admission hold budget exceeded")
}
finish("error", "QUEUE_TIMEOUT")
return
}
defer tk.Release()
// Forward, retrying once on a second healthy endpoint after a connect failure.
upStart := time.Now()
resp, endpoint, err := h.forward(r.Context(), up, path, r, body)
ev.UpstreamMs = time.Since(upStart).Milliseconds()
if err != nil {
if endpoint != "" {
h.d.Reg.MarkDown(up.ID, endpoint)
}
if !earlySSE {
writeErr(w, 503, "UPSTREAM_DOWN", "no upstream endpoint reachable")
} else {
writeSSEError(w, 503, "UPSTREAM_DOWN")
}
finish("error", "UPSTREAM_DOWN")
return
}
defer resp.Body.Close()
h.d.Stats.Observe(up.ID, ev.UpstreamMs)
if parsed.Stream {
respBody, streamTrunc, streamErr := h.pipeStream(w, resp, earlySSE) // Task 6/7
z, tr, fh := h.compress(respBody)
ev.ResponseBodyZst = z
ev.BodyTruncated = ev.BodyTruncated || tr || streamTrunc
if fh != "" {
ev.FullBodySHA256 = fh
}
ev.TokensIn, ev.TokensOut = tokensFromStream(respBody)
if streamErr != nil || resp.StatusCode >= 400 {
finish("error", fmt.Sprintf("UPSTREAM_%d", resp.StatusCode))
return
}
finish("ok", "")
return
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, h.d.Cfg.MaxBodyBytes))
z, tr, fh := h.compress(respBody)
ev.ResponseBodyZst = z
ev.BodyTruncated = ev.BodyTruncated || tr
if fh != "" {
ev.FullBodySHA256 = fh
}
ev.TokensIn, ev.TokensOut = tokensFromJSON(respBody)
copyHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
if resp.StatusCode >= 400 {
finish("error", fmt.Sprintf("UPSTREAM_%d", resp.StatusCode))
return
}
if h.d.Idem != nil && m.IdemKey != "" {
h.d.Idem.Put(r.Context(), m.Tenant, m.IdemKey, resp.StatusCode, respBody)
}
finish("ok", "")
}
// waitForSlot blocks until admission, hold expiry, or client disconnect.
// Full queue-event/keepalive behavior lands in Task 7; this version waits silently.
func (h *handler) waitForSlot(w http.ResponseWriter, r *http.Request, tk pool.Ticket, hold time.Duration, wantEvents bool, upstreamID string, p pool.Pool) (admitted, earlySSE bool) {
select {
case <-tk.Ready():
return true, false
case <-time.After(hold):
cancelOrRelease(tk)
return false, false
case <-r.Context().Done():
cancelOrRelease(tk)
return false, false
}
}
// cancelOrRelease abandons a waiting ticket, but if admission raced our
// timeout (Cancel is a no-op on an admitted ticket), the slot MUST be
// released here — the caller is about to return without deferring Release,
// and a leaked slot permanently erodes pool capacity.
func cancelOrRelease(tk pool.Ticket) {
tk.Cancel()
select {
case <-tk.Ready():
tk.Release()
default:
}
}
func writeSSEError(w http.ResponseWriter, status int, code string) {
fmt.Fprintf(w, "event: error\ndata: {\"status\":%d,\"code\":%q}\n\n", status, code)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
func depthTotal(p pool.Pool) int {
n := 0
for _, v := range p.Depth() {
n += v
}
return n
}
func (h *handler) forward(ctx context.Context, up *config.Upstream, path string, orig *http.Request, body []byte) (*http.Response, string, error) {
var lastEP string
for attempt := 0; attempt < 2; attempt++ {
ep, err := h.d.Reg.PickEndpoint(up.ID)
if err != nil {
return nil, lastEP, err
}
lastEP = ep
// path arrives as /v1/...; endpoint already ends in /v1 → join after stripping.
target := strings.TrimSuffix(ep, "/") + strings.TrimPrefix(path, "/v1")
req, err := http.NewRequestWithContext(ctx, orig.Method, target, bytes.NewReader(body))
if err != nil {
return nil, lastEP, err
}
req.Header.Set("Content-Type", "application/json")
if up.APIKeyEnv != "" {
if k := os.Getenv(up.APIKeyEnv); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
}
resp, err := h.client.Do(req)
if err == nil {
return resp, lastEP, nil
}
h.d.Reg.MarkDown(up.ID, ep)
}
return nil, lastEP, fmt.Errorf("all endpoints failed")
}
func copyHeaders(dst, src http.Header) {
for k, vv := range src {
if k == "Connection" || k == "Transfer-Encoding" {
continue
}
for _, v := range vv {
dst.Add(k, v)
}
}
}
func tokensFromJSON(b []byte) (in, out *int) {
var v struct {
Usage struct {
Prompt *int `json:"prompt_tokens"`
Completion *int `json:"completion_tokens"`
} `json:"usage"`
}
if json.Unmarshal(b, &v) == nil {
return v.Usage.Prompt, v.Usage.Completion
}
return nil, nil
}
// tokensFromStream scans SSE data lines for a chunk carrying usage (present when the
// client asked for stream_options.include_usage; nil otherwise — tokens stay unknown).
func tokensFromStream(raw []byte) (in, out *int) {
for _, line := range bytes.Split(raw, []byte("\n")) {
data, ok := bytes.CutPrefix(line, []byte("data: "))
if !ok || bytes.Equal(data, []byte("[DONE]")) {
continue
}
if i, o := tokensFromJSON(data); i != nil || o != nil {
return i, o
}
}
return nil, nil
}
// pipeStream is completed in Task 6; this stub keeps Task 5 compiling for
// non-streaming tests (streaming tests arrive with Task 6).
func (h *handler) pipeStream(w http.ResponseWriter, resp *http.Response, earlySSE bool) ([]byte, bool, error) {
b, err := io.ReadAll(io.LimitReader(resp.Body, h.d.Cfg.MaxBodyBytes))
copyHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
w.Write(b)
return b, false, err
}
- [ ] Step 6: Run tests
Run: go get github.com/klauspost/compress/zstd && go test ./internal/proxy/ -v -race — Expected: PASS (5 tests).
- [ ] Step 7: Commit
git add internal/proxy/
git commit -m "feat(proxy): non-streaming OpenAI-compat proxy with admission, audit, enforcement
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 6: Streaming passthrough
Files:
- Modify: internal/proxy/handler.go (replace the pipeStream stub)
- Create: internal/proxy/stream.go
- Test: internal/proxy/stream_test.go
Interfaces:
- Produces: (h *handler) pipeStream(w http.ResponseWriter, resp *http.Response, earlySSE bool) (body []byte, truncated bool, err error) — forwards SSE chunk-by-chunk with per-chunk flush; accumulates only bytes confirmed written to the client (write-then-accumulate, using Write's returned count), capped at Audit.BodyCapBytes with truncated reporting whether forwarded bytes were dropped from the audit copy. When earlySSE is true the 200/SSE headers were already committed by waitForSlot (Task 7), so it must not write status/headers again.
- [ ] Step 1: Write the failing test
internal/proxy/stream_test.go:
package proxy
import (
"bufio"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// streamChat performs a real HTTP request (httptest.Server, not Recorder) so
// incremental flushing is observable.
func streamChat(t *testing.T, gwURL, model string, hdr map[string]string) (*http.Response, []string, []time.Time) {
body, _ := json.Marshal(map[string]any{"model": model, "stream": true,
"messages": []map[string]string{{"role": "user", "content": "hi"}}})
req, _ := http.NewRequest("POST", gwURL+"/v1/chat/completions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range hdr {
req.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
var lines []string
var stamps []time.Time
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
if l := sc.Text(); l != "" {
lines = append(lines, l)
stamps = append(stamps, time.Now())
}
}
resp.Body.Close()
return resp, lines, stamps
}
func TestStreamingIsIncrementalAndAudited(t *testing.T) {
up := newStubLLM()
defer up.Close()
d, events := testDeps(t, up.URL, 4, 2, true)
gw := httptest.NewServer(NewHandler(*d))
defer gw.Close()
resp, lines, stamps := streamChat(t, gw.URL, "m1", nil)
if resp.StatusCode != 200 || !strings.HasPrefix(resp.Header.Get("Content-Type"), "text/event-stream") {
t.Fatalf("status/ct: %d %s", resp.StatusCode, resp.Header.Get("Content-Type"))
}
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "tok0") || !strings.Contains(joined, "[DONE]") {
t.Fatalf("missing chunks: %s", joined)
}
// stub sleeps 30ms between chunks: incremental delivery means visible spread.
if stamps[len(stamps)-1].Sub(stamps[0]) < 40*time.Millisecond {
t.Fatal("stream appears buffered, not incremental")
}
if len(*events) != 1 || (*events)[0].Status != "ok" || len((*events)[0].ResponseBodyZst) == 0 {
t.Fatalf("stream not audited: %+v", *events)
}
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/proxy/ -run TestStreaming -v — Expected: FAIL (the Task-5 stub buffers the whole stream, so the incremental-delivery assertion fails).
- [ ] Step 3: Implement
Delete the pipeStream stub from handler.go; create internal/proxy/stream.go:
package proxy
import (
"io"
"net/http"
)
func (h *handler) pipeStream(w http.ResponseWriter, resp *http.Response, earlySSE bool) ([]byte, bool, error) {
if !earlySSE {
copyHeaders(w.Header(), resp.Header)
w.WriteHeader(resp.StatusCode)
}
fl, _ := w.(http.Flusher)
var acc []byte
var truncated bool
buf := make([]byte, 32*1024)
capBytes := h.d.Cfg.Audit.BodyCapBytes
for {
n, err := resp.Body.Read(buf)
if n > 0 {
written, werr := w.Write(buf[:n])
if fl != nil {
fl.Flush()
}
// Audit only bytes confirmed delivered to the client.
if written > 0 {
if len(acc) < capBytes {
take := written
if len(acc)+take > capBytes {
take = capBytes - len(acc)
truncated = true
}
acc = append(acc, buf[:take]...)
} else {
truncated = true
}
}
if werr != nil {
return acc, truncated, werr
}
}
if err == io.EOF {
return acc, truncated, nil
}
if err != nil {
return acc, truncated, err
}
}
}
- [ ] Step 4: Run tests
Run: go test ./internal/proxy/ -v -race — Expected: PASS (all proxy tests including Task 5's).
- [ ] Step 5: Commit
git add internal/proxy/
git commit -m "feat(proxy): incremental SSE streaming passthrough with audit tee
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 7: Queue events + opt-in early SSE + keepalives
Files:
- Modify: internal/proxy/handler.go (replace waitForSlot)
- Test: append to internal/proxy/stream_test.go
Interfaces:
- Consumes: pool.Ticket.Pos(), Stats.ETAMs.
- Behavior: only when the request is streaming AND sends X-Queue-Events: on AND admission does not complete within 150 ms, the gateway commits 200 + Content-Type: text/event-stream early and emits event: queue frames ({"position":N,"eta_ms":M}) every 2 s plus on first wait, and : ka comment lines between frames. On later upstream failure it emits event: error (already wired in Task 5 via writeSSEError). Non-opt-in requests hold silently (no bytes) — their client timeout contract is protected by hold budgets.
- [ ] Step 1: Write the failing test
Append to internal/proxy/stream_test.go:
func TestQueueEventsEmittedWhileHeld(t *testing.T) {
up := newStubLLM()
up.delay = 400 * time.Millisecond
defer up.Close()
d, _ := testDeps(t, up.URL, 1, 1, true)
d.Cfg.HoldBudgetsMs[config.ClassInteractive] = 5000
gw := httptest.NewServer(NewHandler(*d))
defer gw.Close()
blocker := make(chan struct{})
go func() { streamChat(t, gw.URL, "m1", nil); close(blocker) }()
time.Sleep(50 * time.Millisecond) // blocker holds the only slot
_, lines, _ := streamChat(t, gw.URL, "m1", map[string]string{"X-Queue-Events": "on"})
joined := strings.Join(lines, "\n")
if !strings.Contains(joined, "event: queue") || !strings.Contains(joined, `"position":1`) {
t.Fatalf("no queue frame before tokens: %s", joined)
}
if !strings.Contains(joined, "tok0") {
t.Fatalf("stream never completed after queueing: %s", joined)
}
if strings.Index(joined, "event: queue") > strings.Index(joined, "tok0") {
t.Fatal("queue frame arrived after first token")
}
<-blocker
}
Note: bufio.Scanner drops SSE comment lines' emptiness fine — : ka lines appear as : ka strings; no assertion needed on them.
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/proxy/ -run TestQueueEvents -v — Expected: FAIL (silent hold → no event: queue line).
- [ ] Step 3: Implement
Replace waitForSlot in internal/proxy/handler.go:
func (h *handler) waitForSlot(w http.ResponseWriter, r *http.Request, tk pool.Ticket, hold time.Duration, wantEvents bool, upstreamID string, p pool.Pool) (admitted, earlySSE bool) {
deadline := time.NewTimer(hold)
defer deadline.Stop()
// Fast path: admitted almost immediately → no early commit needed.
select {
case <-tk.Ready():
return true, false
case <-time.After(150 * time.Millisecond):
case <-deadline.C:
cancelOrRelease(tk)
return false, false
case <-r.Context().Done():
cancelOrRelease(tk)
return false, false
}
if !wantEvents {
select {
case <-tk.Ready():
return true, false
case <-deadline.C:
cancelOrRelease(tk)
return false, false
case <-r.Context().Done():
cancelOrRelease(tk)
return false, false
}
}
// Opt-in path: commit SSE early and narrate the queue.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
fl, _ := w.(http.Flusher)
emit := func() {
eta := h.d.Stats.ETAMs(upstreamID, tk.Pos()-1, poolSlots(p))
fmt.Fprintf(w, "event: queue\ndata: {\"position\":%d,\"eta_ms\":%d}\n\n", tk.Pos(), eta)
if fl != nil {
fl.Flush()
}
}
emit()
frame := time.NewTicker(2 * time.Second)
ka := time.NewTicker(700 * time.Millisecond)
defer frame.Stop()
defer ka.Stop()
for {
select {
case <-tk.Ready():
return true, true
case <-frame.C:
emit()
case <-ka.C:
fmt.Fprint(w, ": ka\n\n")
if fl != nil {
fl.Flush()
}
case <-deadline.C:
cancelOrRelease(tk)
writeSSEError(w, 429, "QUEUE_TIMEOUT")
return false, true
case <-r.Context().Done():
cancelOrRelease(tk)
return false, true
}
}
}
func poolSlots(p pool.Pool) int { return p.Active() + 1 } // conservative slot estimate for ETA
- [ ] Step 4: Run tests
Run: go test ./internal/proxy/ -v -race — Expected: PASS (all, including Tasks 5–6).
- [ ] Step 5: Commit
git add internal/proxy/
git commit -m "feat(proxy): opt-in queue events, keepalives, early-SSE hold narration
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 8: Idempotency cache (Redis)
Files:
- Create: internal/idem/cache.go
- Test: internal/idem/cache_test.go
Interfaces:
- Consumes: implements proxy.IdemCache.
- Produces: idem.NewRedis(rdb redis.UniversalClient, ttl time.Duration) *RedisIdem with Get/Put matching proxy.IdemCache exactly.
- [ ] Step 1: Write the failing test
internal/idem/cache_test.go:
package idem
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestPutGetIsolatedByTenantAndTTL(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
c := NewRedis(rdb, 300*time.Second)
ctx := context.Background()
if _, _, ok := c.Get(ctx, "t1", "k1"); ok {
t.Fatal("phantom hit")
}
c.Put(ctx, "t1", "k1", 200, []byte(`{"a":1}`))
st, body, ok := c.Get(ctx, "t1", "k1")
if !ok || st != 200 || string(body) != `{"a":1}` {
t.Fatalf("miss: %v %d %s", ok, st, body)
}
if _, _, ok := c.Get(ctx, "t2", "k1"); ok {
t.Fatal("tenant isolation broken")
}
mr.FastForward(301 * time.Second)
if _, _, ok := c.Get(ctx, "t1", "k1"); ok {
t.Fatal("TTL not applied")
}
}
func TestGetFailsOpenWhenRedisDown(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
c := NewRedis(rdb, time.Minute)
mr.SetError("down")
if _, _, ok := c.Get(context.Background(), "t1", "k1"); ok {
t.Fatal("must miss, not error, when redis is down")
}
c.Put(context.Background(), "t1", "k1", 200, []byte("x")) // must not panic
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/idem/ -v — Expected: FAIL (undefined: NewRedis).
- [ ] Step 3: Implement
internal/idem/cache.go:
package idem
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"time"
"github.com/redis/go-redis/v9"
)
type RedisIdem struct {
rdb redis.UniversalClient
ttl time.Duration
}
func NewRedis(rdb redis.UniversalClient, ttl time.Duration) *RedisIdem {
return &RedisIdem{rdb: rdb, ttl: ttl}
}
type entry struct {
Status int `json:"status"`
Body []byte `json:"body"`
}
func key(tenant, k string) string {
h := sha256.Sum256([]byte(k))
return "idem:" + tenant + ":" + hex.EncodeToString(h[:16])
}
func (c *RedisIdem) Get(ctx context.Context, tenant, k string) (int, []byte, bool) {
b, err := c.rdb.Get(ctx, key(tenant, k)).Bytes()
if err != nil {
return 0, nil, false
}
var e entry
if json.Unmarshal(b, &e) != nil {
return 0, nil, false
}
return e.Status, e.Body, true
}
func (c *RedisIdem) Put(ctx context.Context, tenant, k string, status int, body []byte) {
b, err := json.Marshal(entry{Status: status, Body: body})
if err != nil {
return
}
_ = c.rdb.Set(ctx, key(tenant, k), b, c.ttl).Err() // best-effort
}
- [ ] Step 4: Run tests + wire-through test
Run: go test ./internal/idem/ -v — Expected: PASS.
Append a wire-through test to internal/proxy/handler_test.go proving the handler uses it (stub call count stays at 1):
func TestIdempotentReplayAvoidsSecondUpstreamCall(t *testing.T) {
up := newStubLLM()
defer up.Close()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
d, _ := testDeps(t, up.URL, 4, 2, true)
d.Idem = idem.NewRedis(rdb, time.Minute)
h := NewHandler(*d)
hdr := map[string]string{"Idempotency-Key": "same-key", "X-Tenant-Id": "ahu-ocr"}
w1 := chat("m1", hdr, h)
w2 := chat("m1", hdr, h)
if w1.Code != 200 || w2.Code != 200 {
t.Fatalf("codes: %d %d", w1.Code, w2.Code)
}
if w2.Header().Get("X-Idempotent-Replay") != "true" {
t.Fatal("second call not served from cache")
}
if up.calls.Load() != 1 {
t.Fatalf("upstream called %d times, want 1", up.calls.Load())
}
}
(Add imports "github.com/alicebob/miniredis/v2", "github.com/redis/go-redis/v9", "datahive.id/ahu-gpu-manager/internal/idem" to the test file.)
Run: go test ./internal/proxy/ -run TestIdempotent -v — Expected: PASS.
- [ ] Step 5: Commit
git add internal/idem/ internal/proxy/
git commit -m "feat(idem): redis idempotency cache with tenant isolation, wired into proxy
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 9: Server assembly — mux, healthz, queue status, metrics, drain
Files:
- Create: internal/server/server.go, cmd/gateway/main.go
- Test: internal/server/server_test.go
Interfaces:
- Consumes: everything above.
- Produces: server.New(cfg *config.Config, reg *registry.Registry, pools map[string]pool.Pool, emitter *audit.Emitter, ic proxy.IdemCache, stats *proxy.Stats) *Server with Handler() http.Handler and Shutdown(ctx context.Context). Routes: POST /v1/...+POST /synthesis/v1/... → proxy handler; GET /healthz; GET /queue/status; GET /metrics (Prometheus).
- [ ] Step 1: Write the failing test
internal/server/server_test.go:
package server
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"datahive.id/ahu-gpu-manager/internal/audit"
"datahive.id/ahu-gpu-manager/internal/config"
"datahive.id/ahu-gpu-manager/internal/pool"
"datahive.id/ahu-gpu-manager/internal/proxy"
"datahive.id/ahu-gpu-manager/internal/registry"
)
func testServer(t *testing.T) (*Server, *config.Config) {
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) }))
t.Cleanup(up.Close)
cfg := &config.Config{
MaxBodyBytes: 1 << 20, Audit: config.AuditConfig{StreamKey: "ahu.ai.audit", SpoolDir: t.TempDir(), BodyCapBytes: 1024},
HoldBudgetsMs: map[config.Class]int{config.ClassInteractive: 100, config.ClassBatch: 100, config.ClassSystem: 100},
Upstreams: []config.Upstream{{ID: "u1", Type: "llm-openai", Class: "on_prem",
Endpoints: []string{up.URL}, Models: []string{"m1"}, ProbePath: "/", Slots: config.Slots{Total: 2, BatchMax: 1}}},
}
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
reg := registry.New(cfg)
pools := map[string]pool.Pool{"u1": pool.NewLocal(cfg.Upstreams[0].Slots)}
em := audit.NewEmitter(rdb, cfg.Audit.StreamKey, cfg.Audit.SpoolDir)
return New(cfg, reg, pools, em, nil, proxy.NewStats()), cfg
}
func TestHealthzAndQueueStatusAndMetrics(t *testing.T) {
s, _ := testServer(t)
ts := httptest.NewServer(s.Handler())
defer ts.Close()
r, _ := http.Get(ts.URL + "/healthz")
if r.StatusCode != 200 {
t.Fatalf("healthz: %d", r.StatusCode)
}
r, _ = http.Get(ts.URL + "/queue/status?upstream=u1")
var qs struct {
Upstream string `json:"upstream"`
Active int `json:"active"`
Depth map[string]int `json:"depth"`
EtaMs int64 `json:"eta_ms"`
}
if err := json.NewDecoder(r.Body).Decode(&qs); err != nil || qs.Upstream != "u1" {
t.Fatalf("queue status: %v %+v", err, qs)
}
r, _ = http.Get(ts.URL + "/metrics")
if r.StatusCode != 200 {
t.Fatalf("metrics: %d", r.StatusCode)
}
}
func TestDrainRejectsNewFinishesInflight(t *testing.T) {
s, _ := testServer(t)
ts := httptest.NewServer(s.Handler())
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go s.Shutdown(ctx)
time.Sleep(50 * time.Millisecond)
r, err := http.Post(ts.URL+"/v1/chat/completions", "application/json",
strings.NewReader(`{"model":"m1"}`))
if err != nil {
t.Fatal(err)
}
if r.StatusCode != 503 || r.Header.Get("Retry-After") == "" {
t.Fatalf("draining server must 503+Retry-After new work, got %d", r.StatusCode)
}
rh, _ := http.Get(ts.URL + "/healthz")
if rh.StatusCode != 503 {
t.Fatalf("healthz must fail during drain for LB removal, got %d", rh.StatusCode)
}
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/server/ -v — Expected: FAIL (undefined: New).
- [ ] Step 3: Implement server
internal/server/server.go:
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"datahive.id/ahu-gpu-manager/internal/audit"
"datahive.id/ahu-gpu-manager/internal/config"
"datahive.id/ahu-gpu-manager/internal/pool"
"datahive.id/ahu-gpu-manager/internal/proxy"
"datahive.id/ahu-gpu-manager/internal/registry"
)
type Server struct {
cfg *config.Config
reg *registry.Registry
pools map[string]pool.Pool
emitter *audit.Emitter
mux *http.ServeMux
draining atomic.Bool
inflight sync.WaitGroup
promReg *prometheus.Registry
reqTotal *prometheus.CounterVec
queueWait prometheus.Histogram
}
func New(cfg *config.Config, reg *registry.Registry, pools map[string]pool.Pool,
em *audit.Emitter, ic proxy.IdemCache, stats *proxy.Stats) *Server {
s := &Server{cfg: cfg, reg: reg, pools: pools, emitter: em, mux: http.NewServeMux()}
// Per-server registry: the default global registry would panic on duplicate
// registration when tests construct more than one Server per process.
s.promReg = prometheus.NewRegistry()
factory := promauto.With(s.promReg)
s.reqTotal = factory.NewCounterVec(prometheus.CounterOpts{Name: "gateway_requests_total"},
[]string{"tenant", "upstream", "class", "status"})
s.queueWait = factory.NewHistogram(prometheus.HistogramOpts{
Name: "gateway_queue_wait_ms", Buckets: []float64{10, 50, 100, 500, 1000, 5000, 15000, 45000, 120000}})
emit := func(ev audit.Event) {
s.reqTotal.WithLabelValues(ev.Engine, ev.Upstream, ev.TrafficClass, ev.Status).Inc()
s.queueWait.Observe(float64(ev.QueueMs))
em.Emit(ev)
}
ph := proxy.NewHandler(proxy.Deps{Cfg: cfg, Reg: reg, Pools: pools, Emit: emit, Idem: ic, Stats: stats})
guard := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.draining.Load() {
w.Header().Set("Retry-After", "5")
http.Error(w, `{"error":{"code":"DRAINING"}}`, http.StatusServiceUnavailable)
return
}
s.inflight.Add(1)
defer s.inflight.Done()
next.ServeHTTP(w, r)
})
}
s.mux.Handle("/v1/", guard(ph))
s.mux.Handle("/synthesis/v1/", guard(ph))
s.mux.HandleFunc("GET /healthz", s.healthz)
s.mux.HandleFunc("GET /queue/status", func(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("upstream")
p, ok := s.pools[id]
if !ok {
http.Error(w, `{"error":{"code":"UNKNOWN_UPSTREAM"}}`, 404)
return
}
depth := map[string]int{}
total := 0
for c, n := range p.Depth() {
depth[string(c)] = n
total += n
}
json.NewEncoder(w).Encode(map[string]any{
"upstream": id, "active": p.Active(), "depth": depth,
"eta_ms": stats.ETAMs(id, total, p.Active()+1),
})
})
s.mux.Handle("GET /metrics", promhttp.HandlerFor(s.promReg, promhttp.HandlerOpts{}))
return s
}
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
if s.draining.Load() {
http.Error(w, "draining", http.StatusServiceUnavailable)
return
}
fmt.Fprintf(w, `{"status":"ok","upstreams":%d,"audit_dropped":%d}`, len(s.pools), s.emitter.Dropped())
}
func (s *Server) Handler() http.Handler { return s.mux }
func (s *Server) Shutdown(ctx context.Context) {
s.draining.Store(true)
done := make(chan struct{})
go func() { s.inflight.Wait(); close(done) }()
select {
case <-done:
case <-ctx.Done():
}
s.emitter.Flush(5_000_000_000) // 5s
}
- [ ] Step 4: Implement main
cmd/gateway/main.go:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/redis/go-redis/v9"
"datahive.id/ahu-gpu-manager/internal/audit"
"datahive.id/ahu-gpu-manager/internal/config"
"datahive.id/ahu-gpu-manager/internal/idem"
"datahive.id/ahu-gpu-manager/internal/pool"
"datahive.id/ahu-gpu-manager/internal/proxy"
"datahive.id/ahu-gpu-manager/internal/registry"
"datahive.id/ahu-gpu-manager/internal/server"
)
func main() {
cfgPath := os.Getenv("GATEWAY_CONFIG")
if cfgPath == "" {
cfgPath = "/etc/ahu-gateway/gateway.yaml"
}
cfg, err := config.Load(cfgPath)
if err != nil {
log.Fatalf("config: %v", err)
}
opts, err := redis.ParseURL(cfg.RedisURL)
if err != nil {
log.Fatalf("redis url: %v", err)
}
rdb := redis.NewClient(opts)
reg := registry.New(cfg)
pools := map[string]pool.Pool{}
for i := range cfg.Upstreams {
u := &cfg.Upstreams[i]
if cfg.Coordination == "redis" {
pools[u.ID] = pool.NewRedisPool(rdb, u.ID, u.Slots) // Task 10
} else {
pools[u.ID] = pool.NewLocal(u.Slots)
}
}
em := audit.NewEmitter(rdb, cfg.Audit.StreamKey, cfg.Audit.SpoolDir)
srv := server.New(cfg, reg, pools, em, idem.NewRedis(rdb, 5*time.Minute), proxy.NewStats())
ctx, cancel := context.WithCancel(context.Background())
go em.Run(ctx)
reg.StartProber(ctx, 15*time.Second)
hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler()}
go func() {
log.Printf("gateway listening on %s (%d upstreams)", cfg.Listen, len(cfg.Upstreams))
if err := hs.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
<-sig
log.Print("draining...")
dctx, dcancel := context.WithTimeout(context.Background(), 30*time.Second)
defer dcancel()
srv.Shutdown(dctx)
hs.Shutdown(dctx)
cancel()
}
(Until Task 10 exists, temporarily guard the coordination == "redis" branch with log.Fatalf("redis coordination arrives in Task 10") so the package compiles; Task 10 replaces it.)
- [ ] Step 5: Run tests + build
Run: go get github.com/prometheus/client_golang/prometheus && go test ./... -count=1 && go build ./cmd/gateway — Expected: all PASS; binary builds.
- [ ] Step 6: Commit
git add internal/server/ cmd/
git commit -m "feat(server): mux assembly, healthz, queue status, prometheus metrics, graceful drain
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Amendment (post-review, commit
d97c1a3): the drain design above (atomic.Bool + sync.WaitGroup) has a TOCTOU that can panic mid-drain (Add racing Wait at counter zero — empirically reproduced 6/8 runs under stress). The shippedinternal/server/server.goreplaces it with a mutex-guarded gate (enter()/leave()/isDraining()+drainDonechannel) making check-and-admit a single critical section, plusReadHeaderTimeout: 10 * time.Secondon the http.Server. The repo code is authoritative for this task; do not regenerate from the snippet above.
Task 10: RedisPool — cluster-wide slots for the HA pair
Files:
- Create: internal/pool/redispool.go
- Modify: cmd/gateway/main.go (drop the Task-9 guard)
- Test: internal/pool/redispool_test.go
Interfaces:
- Produces: pool.NewRedisPool(rdb redis.UniversalClient, upstreamID string, s config.Slots) Pool — same Pool/Ticket interfaces as NewLocal. Semantics: cluster-wide Total/BatchMax enforced via a lease ZSET (gwslots:{upstream} / gwslots:{upstream}:batch, score = lease expiry unix-ms, 30 s leases refreshed every 10 s while held); waiters poll every 100 ms; fail-open: on Redis error, acquisition falls back to an embedded LocalPool.
- [ ] Step 1: Write the failing test
internal/pool/redispool_test.go:
package pool
import (
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"datahive.id/ahu-gpu-manager/internal/config"
)
func rpAdmitted(t Ticket, d time.Duration) bool {
select {
case <-t.Ready():
return true
case <-time.After(d):
return false
}
}
func TestTwoInstancesShareCap(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := config.Slots{Total: 1, BatchMax: 1}
p1 := NewRedisPool(rdb, "u1", s)
p2 := NewRedisPool(rdb, "u1", s)
a := p1.Enqueue(config.ClassInteractive)
if !rpAdmitted(a, time.Second) {
t.Fatal("first not admitted")
}
b := p2.Enqueue(config.ClassInteractive)
if rpAdmitted(b, 300*time.Millisecond) {
t.Fatal("cap not shared across instances")
}
a.Release()
if !rpAdmitted(b, time.Second) {
t.Fatal("b not admitted after cross-instance release")
}
b.Release()
}
func TestLeaseExpiryFreesSlotAfterCrash(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
s := config.Slots{Total: 1, BatchMax: 1}
p1 := NewRedisPool(rdb, "u1", s)
a := p1.Enqueue(config.ClassInteractive)
rpAdmitted(a, time.Second)
// simulate crash: no Release, no refresh; jump past lease TTL
p1.(*redisPool).stopRefresh()
mr.FastForward(31 * time.Second)
p2 := NewRedisPool(rdb, "u1", s)
b := p2.Enqueue(config.ClassInteractive)
if !rpAdmitted(b, 2*time.Second) {
t.Fatal("expired lease never reclaimed")
}
b.Release()
}
func TestFailOpenWhenRedisDown(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
p := NewRedisPool(rdb, "u1", config.Slots{Total: 2, BatchMax: 1})
mr.SetError("down")
a := p.Enqueue(config.ClassInteractive)
if !rpAdmitted(a, time.Second) {
t.Fatal("fail-open broken: request blocked by dead redis")
}
a.Release()
}
- [ ] Step 2: Run test to verify it fails
Run: go test ./internal/pool/ -run TestTwoInstances -v — Expected: FAIL (undefined: NewRedisPool).
- [ ] Step 3: Implement
internal/pool/redispool.go:
package pool
import (
"context"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"datahive.id/ahu-gpu-manager/internal/config"
)
const (
leaseTTL = 30 * time.Second
refreshTick = 10 * time.Second
pollTick = 100 * time.Millisecond
)
// acquire: prune expired leases, then take a slot iff total (and batch, when
// applicable) caps have headroom. KEYS[1]=all ZSET, KEYS[2]=batch ZSET.
// ARGV: now_ms, expiry_ms, lease_id, total_cap, batch_cap, is_batch
var acquireScript = redis.NewScript(`
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', ARGV[1])
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[4]) then return 0 end
if ARGV[6] == '1' and redis.call('ZCARD', KEYS[2]) >= tonumber(ARGV[5]) then return 0 end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
if ARGV[6] == '1' then redis.call('ZADD', KEYS[2], ARGV[2], ARGV[3]) end
return 1
`)
type redisPool struct {
rdb redis.UniversalClient
id string
slots config.Slots
local Pool // fail-open fallback and local FIFO/priority ordering
mu sync.Mutex
refresh map[string]context.CancelFunc
}
func NewRedisPool(rdb redis.UniversalClient, upstreamID string, s config.Slots) Pool {
return &redisPool{rdb: rdb, id: upstreamID, slots: s,
local: NewLocal(s), refresh: map[string]context.CancelFunc{}}
}
func (p *redisPool) keys() (string, string) {
return "gwslots:" + p.id, "gwslots:" + p.id + ":batch"
}
type rticket struct {
inner Ticket // local ticket: preserves class priority + position locally
p *redisPool
class config.Class
leaseID string
ready chan struct{}
cancel chan struct{}
once sync.Once
}
func (t *rticket) Ready() <-chan struct{} { return t.ready }
func (t *rticket) Pos() int { return t.inner.Pos() }
func (t *rticket) Cancel() {
t.once.Do(func() { close(t.cancel) })
t.inner.Cancel()
}
func (t *rticket) Release() {
t.inner.Release()
t.p.mu.Lock()
if c, ok := t.p.refresh[t.leaseID]; ok {
c()
delete(t.p.refresh, t.leaseID)
}
t.p.mu.Unlock()
all, batch := t.p.keys()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
t.p.rdb.ZRem(ctx, all, t.leaseID)
if t.class == config.ClassBatch {
t.p.rdb.ZRem(ctx, batch, t.leaseID)
}
}
func (p *redisPool) Enqueue(class config.Class) Ticket {
t := &rticket{inner: p.local.Enqueue(class), p: p, class: class,
leaseID: fmt.Sprintf("%s-%d", p.id, time.Now().UnixNano()),
ready: make(chan struct{}), cancel: make(chan struct{})}
go func() {
// Stage 1: local admission (priority order among this instance's waiters).
select {
case <-t.inner.Ready():
case <-t.cancel:
return
}
// Stage 2: cluster-wide lease.
tick := time.NewTicker(pollTick)
defer tick.Stop()
for {
ok, err := p.tryAcquire(class, t.leaseID)
if err != nil {
close(t.ready) // fail-open: local pool already granted a slot
return
}
if ok {
p.startRefresh(t.leaseID, class)
close(t.ready)
return
}
select {
case <-tick.C:
case <-t.cancel:
return
}
}
}()
return t
}
func (p *redisPool) tryAcquire(class config.Class, leaseID string) (bool, error) {
all, batch := p.keys()
now := time.Now().UnixMilli()
isBatch := "0"
if class == config.ClassBatch {
isBatch = "1"
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
n, err := acquireScript.Run(ctx, p.rdb, []string{all, batch},
now, now+leaseTTL.Milliseconds(), leaseID, p.slots.Total, p.slots.BatchMax, isBatch).Int()
if err != nil {
return false, err
}
return n == 1, nil
}
func (p *redisPool) startRefresh(leaseID string, class config.Class) {
ctx, cancel := context.WithCancel(context.Background())
p.mu.Lock()
p.refresh[leaseID] = cancel
p.mu.Unlock()
go func() {
tick := time.NewTicker(refreshTick)
defer tick.Stop()
all, batch := p.keys()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
exp := float64(time.Now().Add(leaseTTL).UnixMilli())
c, cancel2 := context.WithTimeout(context.Background(), time.Second)
p.rdb.ZAdd(c, all, redis.Z{Score: exp, Member: leaseID})
if class == config.ClassBatch {
p.rdb.ZAdd(c, batch, redis.Z{Score: exp, Member: leaseID})
}
cancel2()
}
}
}()
}
func (p *redisPool) stopRefresh() { // test hook: simulate crashed holder
p.mu.Lock()
defer p.mu.Unlock()
for id, c := range p.refresh {
c()
delete(p.refresh, id)
}
}
func (p *redisPool) Depth() map[config.Class]int { return p.local.Depth() }
func (p *redisPool) Active() int { return p.local.Active() }
Also remove the Task-9 log.Fatalf guard in cmd/gateway/main.go so coordination: redis constructs pool.NewRedisPool(rdb, u.ID, u.Slots).
- [ ] Step 4: Run tests
Run: go test ./internal/pool/ -v -race && go build ./cmd/gateway — Expected: PASS (all pool tests).
- [ ] Step 5: Commit
git add internal/pool/ cmd/
git commit -m "feat(pool): redis-coordinated cluster-wide slots with lease expiry and fail-open
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Amendment (post-review, commit
dbc8d3a): the prescribedTestLeaseExpiryFreesSlotAfterCrashis impossible as written —miniredis.FastForwardadvances Redis-native TTLs only, never the Gotime.Now()that lease ZSET scores compare against. Shipped test seeds an already-expired lease (score in the past) and asserts a new pool instance reclaims it, which pins the LuaZREMRANGEBYSCOREprune path directly (verified: commenting the prune line fails the test). ThestopRefresh()test hook was deleted as unused. Repo code is authoritative.
Task 11: Packaging — Dockerfile, compose, example config, README, smoke script
Files:
- Create: Dockerfile, deploy/compose.yaml, deploy/gateway.example.yaml, scripts/smoke.sh, README.md
Interfaces:
- Consumes: the built binary; env GATEWAY_CONFIG, DASHSCOPE_API_KEY.
- [ ] Step 1: Dockerfile
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /gateway ./cmd/gateway
FROM alpine:3.20
RUN adduser -D -H gateway && mkdir -p /var/lib/ahu-gateway/spool && chown gateway /var/lib/ahu-gateway/spool
USER gateway
COPY --from=build /gateway /usr/local/bin/gateway
EXPOSE 8200
ENTRYPOINT ["/usr/local/bin/gateway"]
- [ ] Step 2: Example config (matches the x056 survey — see docs/deploy/)
deploy/gateway.example.yaml:
listen: ":8200"
allow_external_upstreams: true # MUST be false in production
coordination: local # switch to "redis" when running the HA pair
redis_url: "redis://ahu-platform-redis:6379/0"
audit:
stream_key: "ahu.ai.audit"
spool_dir: "/var/lib/ahu-gateway/spool"
body_cap_bytes: 10485760
hold_budgets_ms: {interactive: 45000, batch: 120000, system: 10000}
route_prefixes:
synthesis: ext-dashscope-397b
tenants:
- {id: ahu-chatbot, token: ""} # set tokens to enforce auth; empty = header-trust mode
- {id: ahu-ocr, token: ""}
- {id: doc-classifier, token: ""}
upstreams:
- id: qwen-35b
type: llm-openai
class: on_prem
endpoints: ["http://192.168.83.20:8001/v1"] # ahu-vllm, host-published
models: ["Qwen/Qwen3.6-35B-A3B-FP8", "qwen-35b"]
slots: {total: 16, batch_max: 8}
- id: cleanup-3b
type: llm-openai
class: on_prem
endpoints: ["http://192.168.83.20:8003/v1"]
models: ["cleanup-3b"]
slots: {total: 8, batch_max: 8}
- id: tei-embeddings
type: embedding
class: on_prem
endpoints: ["http://192.168.83.20:8100/v1"]
models: ["Qwen/Qwen3-Embedding-4B"]
slots: {total: 8, batch_max: 8}
- id: ext-dashscope-397b
type: llm-openai
class: external_dev
endpoints: ["https://ws-08046ecd.cn-beijing.dashscope.aliyuncs.com/compatible-mode/v1"]
api_key_env: DASHSCOPE_API_KEY
models: ["qwen3.5-397b-a17b"]
slots: {total: 8, batch_max: 4}
(Verify the DashScope base URL against ai-ahu-chatbot/infra/env/*.env at deploy time — read-only check, do not modify that repo.)
- [ ] Step 3: Compose stack
deploy/compose.yaml:
name: ahu-platform
services:
gateway:
build: ..
restart: unless-stopped
ports: ["8200:8200"]
environment:
GATEWAY_CONFIG: /etc/ahu-gateway/gateway.yaml
DASHSCOPE_API_KEY: ${DASHSCOPE_API_KEY:-}
volumes:
- ./gateway.yaml:/etc/ahu-gateway/gateway.yaml:ro
- gateway-spool:/var/lib/ahu-gateway/spool
depends_on: [redis]
redis:
image: redis:7-alpine
container_name: ahu-platform-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes: [redis-data:/data]
volumes:
gateway-spool:
redis-data:
- [ ] Step 4: Smoke script
scripts/smoke.sh:
#!/usr/bin/env bash
# Usage: GW=http://localhost:8200 MODEL="Qwen/Qwen3.6-35B-A3B-FP8" ./scripts/smoke.sh
set -euo pipefail
GW="${GW:-http://localhost:8200}"
MODEL="${MODEL:-qwen-35b}"
echo "== healthz"; curl -sf "$GW/healthz"; echo
echo "== queue status"; curl -sf "$GW/queue/status?upstream=qwen-35b"; echo
echo "== chat (non-streaming)"
curl -sf "$GW/v1/chat/completions" -H 'Content-Type: application/json' \
-H 'X-Tenant-Id: smoke' -H 'X-Priority: batch' -H "Idempotency-Key: smoke-$(date +%s)" \
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"Say OK\"}],\"max_tokens\":5}"
echo; echo "== chat (streaming, queue events on)"
curl -sfN "$GW/v1/chat/completions" -H 'Content-Type: application/json' \
-H 'X-Tenant-Id: smoke' -H 'X-Queue-Events: on' \
-d "{\"model\":\"$MODEL\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"Count to 3\"}],\"max_tokens\":20}" | head -20
echo; echo "== audit stream depth"
docker exec ahu-platform-redis redis-cli XLEN ahu.ai.audit
echo "smoke OK"
chmod +x scripts/smoke.sh
- [ ] Step 5: README
README.md — quickstart (build, test, compose up with cp deploy/gateway.example.yaml deploy/gateway.yaml, run smoke), pointer table to docs/CONVENTIONS.md, the spec, the deploy survey, and the integration prompts. Keep under 80 lines. Upload after writing: curl -F "file=@README.md" https://x056.think.val.id/upload.
- [ ] Step 6: Verify build end-to-end
Run: go vet ./... && go test ./... -count=1 && docker build -t ahu-gateway:dev . — Expected: tests PASS, image builds. (If Docker is unavailable locally, note it and verify the static build with CGO_ENABLED=0 go build ./cmd/gateway.)
- [ ] Step 7: Commit
git add Dockerfile deploy/ scripts/ README.md
git commit -m "feat(deploy): dockerfile, compose stack, x056 example config, smoke script
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
Task 12: Live-fire verification against a real vLLM-shaped upstream (P0 exit)
Files:
- Create: scripts/loadcheck.sh
- No production-code changes expected; fixes discovered here get their own micro-commits.
- [ ] Step 1: Stand the stack up locally
cd deploy && cp gateway.example.yaml gateway.yaml
# For a machine without GPU access, point qwen-35b endpoints at the x056 vLLM
# (http://192.168.83.20:8001/v1 — reachable per the deploy survey) or run a local stub.
docker compose up -d --build
GW=http://localhost:8200 MODEL="Qwen/Qwen3.6-35B-A3B-FP8" ../scripts/smoke.sh
Expected: all four smoke sections succeed; XLEN ahu.ai.audit ≥ 2.
- [ ] Step 2: Real-client compatibility check (openai-python, mimicking the engines)
uv run --with openai python3 - <<'EOF'
from openai import OpenAI
c = OpenAI(base_url="http://localhost:8200/v1", api_key="EMPTY",
default_headers={"X-Tenant-Id": "compat-check", "X-Priority": "interactive"})
r = c.chat.completions.create(model="Qwen/Qwen3.6-35B-A3B-FP8",
messages=[{"role": "user", "content": "Say OK"}], max_tokens=5)
print("non-stream:", r.choices[0].message.content)
s = c.chat.completions.create(model="Qwen/Qwen3.6-35B-A3B-FP8", stream=True,
messages=[{"role": "user", "content": "Count to 3"}], max_tokens=20)
print("stream:", "".join(ch.choices[0].delta.content or "" for ch in s if ch.choices))
EOF
Expected: both print content; no SDK parse errors (this validates keepalive/queue-frame tolerance of the exact client the engines use).
- [ ] Step 3: Saturation behavior under parallel load
scripts/loadcheck.sh:
#!/usr/bin/env bash
# Fires 40 concurrent batch requests at a pool of 16; verifies queueing + 429s are clean.
set -euo pipefail
GW="${GW:-http://localhost:8200}"; MODEL="${MODEL:-Qwen/Qwen3.6-35B-A3B-FP8}"
seq 40 | xargs -P40 -I{} curl -s -o /dev/null -w "%{http_code}\n" \
"$GW/v1/chat/completions" -H 'Content-Type: application/json' \
-H 'X-Tenant-Id: loadcheck' -H 'X-Priority: batch' \
-d "{\"model\":\"$MODEL\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":8}" \
| sort | uniq -c
Expected output: a mix of 200 and (if the 120 s batch hold is exceeded) 429 — zero 5xx, zero connection resets. Then: curl -s localhost:8200/metrics | grep gateway_queue_wait shows non-zero histogram counts.
- [ ] Step 4: Kill-the-gateway drill (drop-detection contract)
docker compose restart gateway & sleep 0.2
curl -s -o /dev/null -w "%{http_code}\n" localhost:8200/healthz || true
sleep 3
GW=http://localhost:8200 ../scripts/smoke.sh
docker exec ahu-platform-redis redis-cli XLEN ahu.ai.audit
Expected: brief connection errors during restart (engines' retry contract handles this), full recovery after; audit stream length strictly grew; spool volume empty (docker compose exec gateway ls /var/lib/ahu-gateway/spool → no files).
- [ ] Step 5: Commit + tag P0
git add scripts/loadcheck.sh
git commit -m "test(p0): smoke, real-client compat, saturation and restart drills
Claude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT"
git tag p0-gateway
Self-review notes (performed at plan time)
- Spec coverage: §4.1 registry → Tasks 1–2; §4.2 classes/pools → Task 3 (+10 for HA); §4.3(a) OpenAI passthrough → Tasks 5–6; §4.4 queue-awareness → Tasks 5, 7; §4.5 idempotency/drop-detection → Tasks 8, 12; §5.1 event emission side → Task 4; §3 HA software obligations (stateless, Redis slots, fail-open, drain) → Tasks 9–10; §7 testing (real clients, chaos-lite, load) → Task 12; §8 P0/P1 packaging → Task 11. Deliberately out of this plan: Job API + fan-out + OCR adapters (P2), controller/node-agent/autoscaler (P3), and the entire observatory consumer side (separate plan; the Redis Stream is the boundary, and the emitter's at-least-once note obliges that plan to dedupe on
event_id). - Type consistency check:
pool.Ticket/Poolused identically in Tasks 3, 5, 7, 10;proxy.IdemCachedefined Task 5, implemented Task 8;audit.Eventfields match CONVENTIONS §5 (withclassifyin the operation enum unused by the gateway — emitted only by the classifier SDK). - Known simplifications (explicit, not accidental): streaming requests are not idempotency-replayed (re-run instead — documented in Task 5 behavior contract); token counts for streams are null unless the client requests
stream_options.include_usage;/queue/statusETA is an EWMA heuristic, not a promise.