Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions cmd/spinloop/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
func gatewayCmd() *cobra.Command {
var fleetPath, listen, apiToken, apiTokenFile string
var wakeTimeout time.Duration
var maxRequestBytes int64
var loopback bool
c := &cobra.Command{
Use: "gateway",
Expand All @@ -51,7 +52,7 @@ The agent then needs only the gateway's token, as OPENAI_API_KEY.`,
SilenceUsage: true,
RunE: func(c *cobra.Command, args []string) error {
resolve(c)
return runGatewayCommand(fleetPath, listen, apiToken, apiTokenFile, wakeTimeout, loopback, c.Flags())
return runGatewayCommand(fleetPath, listen, apiToken, apiTokenFile, wakeTimeout, maxRequestBytes, loopback, c.Flags())
},
}
fs := c.Flags()
Expand All @@ -61,6 +62,7 @@ The agent then needs only the gateway's token, as OPENAI_API_KEY.`,
fs.StringVar(&apiTokenFile, "api-token-file", "", "read the gateway's bearer token from this file")
fs.StringVar(&apiToken, "api-token", "", "the gateway's bearer token")
fs.DurationVar(&wakeTimeout, "wake-timeout", 0, "how long to wait for a woken engine to answer")
fs.Int64Var(&maxRequestBytes, "max-request-bytes", gateway.DefaultMaxRequestBytes, "the largest completion request body to accept, in bytes")
compRegister(c, "fleet", compFiles)
return c
}
Expand All @@ -70,7 +72,7 @@ func cmdGateway(args []string) error { return execCmd(gatewayCmd(), args) }

// runGatewayCommand is the body of `spinloop gateway`: the server, and the
// signal handling that shuts it down cleanly.
func runGatewayCommand(fleetPath, listen, apiToken, apiTokenFile string, wakeTimeout time.Duration, loopback bool, flags *pflag.FlagSet) error {
func runGatewayCommand(fleetPath, listen, apiToken, apiTokenFile string, wakeTimeout time.Duration, maxRequestBytes int64, loopback bool, flags *pflag.FlagSet) error {
// Whether --listen was typed at all, not whether it differs from the
// default: --listen :4000 --loopback is still a conflict, and a
// compare-against-default check would let it pass.
Expand All @@ -88,7 +90,10 @@ func runGatewayCommand(fleetPath, listen, apiToken, apiTokenFile string, wakeTim
restore = func() { fleet.WakeTimeout = prev }
defer restore()
}
srv, ln, err := newGatewayServer(fleetPath, listen, apiToken, apiTokenFile)
if maxRequestBytes < 1 {
return fmt.Errorf("--max-request-bytes must be at least 1, got %d", maxRequestBytes)
}
srv, ln, err := newGatewayServer(fleetPath, listen, apiToken, apiTokenFile, maxRequestBytes)
if err != nil {
return err
}
Expand Down Expand Up @@ -128,7 +133,7 @@ func gatewayListenAddr(listen string, listenExplicit, loopback bool) (string, er
// file's token references the way a startup must, opens the listener, and
// prints the address a fleet file's gateway section names. Everything that can
// fail without serving fails here, before a listener exists.
func newGatewayServer(fleetPath, listen, apiToken, apiTokenFile string) (*http.Server, net.Listener, error) {
func newGatewayServer(fleetPath, listen, apiToken, apiTokenFile string, maxRequestBytes int64) (*http.Server, net.Listener, error) {
cfg, err := fleet.Resolve(fleetPath)
if err != nil {
return nil, nil, err
Expand Down Expand Up @@ -176,7 +181,7 @@ func newGatewayServer(fleetPath, listen, apiToken, apiTokenFile string) (*http.S
return deployConfigForNode(sel, path)
}

h := gateway.New(cfg, token, gateway.Options{ConfigFor: cfgFor, Log: logger})
h := gateway.New(cfg, token, gateway.Options{ConfigFor: cfgFor, Log: logger, MaxRequestBytes: maxRequestBytes})
ln, err := gateway.Listen(listen, token)
if err != nil {
return nil, nil, err
Expand Down
17 changes: 12 additions & 5 deletions cmd/spinloop/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func TestGatewayStartsAndAnswers(t *testing.T) {
var ln net.Listener
out := captureStdout(t, func() {
var err error
srv, ln, err = newGatewayServer("", "127.0.0.1:0", "", "")
srv, ln, err = newGatewayServer("", "127.0.0.1:0", "", "", 0)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -68,7 +68,7 @@ func TestGatewayStartsAndAnswers(t *testing.T) {
// A missing fleet file fails naming the expected path, and nothing listens.
func TestGatewayFailsWithoutAFleetFile(t *testing.T) {
t.Chdir(t.TempDir())
_, ln, err := newGatewayServer("", "127.0.0.1:0", "", "")
_, ln, err := newGatewayServer("", "127.0.0.1:0", "", "", 0)
if err == nil {
t.Fatal("a gateway with no fleet file should fail")
}
Expand All @@ -87,7 +87,7 @@ func TestGatewayFailsOnAnUnsetTokenVariable(t *testing.T) {
fleetFileIn(t, dir, "nodes:\n - name: gated\n host: 127.0.0.1\n port: 14242\n tokenEnv: GW_NODE_TOKEN_UNSET\n")
t.Chdir(dir)

_, ln, err := newGatewayServer("", "127.0.0.1:0", "", "")
_, ln, err := newGatewayServer("", "127.0.0.1:0", "", "", 0)
if err == nil {
t.Fatal("an unset token variable should fail the gateway at startup")
}
Expand All @@ -109,7 +109,7 @@ func TestGatewayTokenSourcesConflict(t *testing.T) {

tokenFile := filepath.Join(t.TempDir(), "token")
mustWrite(t, tokenFile, "from-file\n")
_, _, err := newGatewayServer("", "127.0.0.1:0", "literal", tokenFile)
_, _, err := newGatewayServer("", "127.0.0.1:0", "literal", tokenFile, 0)
if err == nil {
t.Fatal("two token sources should be a conflict")
}
Expand All @@ -126,7 +126,7 @@ func TestGatewayRefusesTokenlessNonLoopback(t *testing.T) {
t.Chdir(dir)
t.Setenv("SPINLOOP_API_TOKEN", "")

_, ln, err := newGatewayServer("", "0.0.0.0:0", "", "")
_, ln, err := newGatewayServer("", "0.0.0.0:0", "", "", 0)
if err == nil {
t.Fatal("a tokenless non-loopback gateway should refuse to start")
}
Expand Down Expand Up @@ -274,3 +274,10 @@ func TestFleetURL(t *testing.T) {
}
}
}

func TestGatewayRefusesANonPositiveRequestLimit(t *testing.T) {
err := cmdGateway([]string{"--max-request-bytes", "0"})
if err == nil || !strings.Contains(err.Error(), "--max-request-bytes") {
t.Fatalf("got %v, want an error naming --max-request-bytes", err)
}
}
8 changes: 4 additions & 4 deletions cmd/spinloop/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ func TestCmdOrchestrator_AFleetFileNamesTheGateway(t *testing.T) {
mustWrite(t, filepath.Join(dir, ".env"), "ORCH_FILE_TOKEN=the-token\n")
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n tags:\n - gpu=a100\n")

srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "")
srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "", 0)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -266,7 +266,7 @@ func TestCmdOrchestrator_WorksAgainstAGatewayOnLoopback(t *testing.T) {
t.Chdir(dir)
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n tags:\n - gpu=a100\n")

srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "")
srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "", 0)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -399,7 +399,7 @@ func TestCmdOrchestrator_LoopbackServesTheWorkListWithoutAToken(t *testing.T) {
// The item names a tag no node carries, so nothing is launched.
mustWrite(t, "work.yaml", "- id: a\n instructions: do\n dir: .\n tags:\n - gpu=a100\n")

srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "")
srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "", 0)
if err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -495,7 +495,7 @@ func TestCmdOrchestrator_StartupShowsARestartsRecoveredState(t *testing.T) {
t.Fatal(err)
}

srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "")
srv, ln, err := newGatewayServer("", "127.0.0.1:0", "", "", 0)
if err != nil {
t.Fatal(err)
}
Expand Down
8 changes: 8 additions & 0 deletions docs/commands/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,13 @@ The gateway needs the same environment a machine running
`spinloop fleet start` would: the tokens the fleet file names, set in its
process environment or in a `.env` beside the fleet file.

### Request size

A completion request body is read in full or refused. The limit is 64 MiB by
default, set with `--max-request-bytes`. A larger body is answered `413`, naming
the limit and the flag, and nothing is sent to an engine. A long-context model
fed whole conversations may need the limit raised.

## The gateway's token

Callers present the gateway's token as a bearer token on every request — a
Expand Down Expand Up @@ -196,6 +203,7 @@ on a shared machine wants, and the reason the token is not optional there.
| `--api-token-file` | Read the gateway's bearer token from this file |
| `--api-token` | The gateway's bearer token |
| `--wake-timeout` | How long to wait for a woken engine to answer (default 5m) |
| `--max-request-bytes` | The largest completion request body to accept, in bytes (default 67108864, 64 MiB); a larger one is answered `413` |

## See also

Expand Down
37 changes: 31 additions & 6 deletions internal/gateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ import (
// one address without knowing the machine it lands on.
const DefaultListen = ":4000"

// DefaultMaxRequestBytes is the largest completion request body the gateway
// reads when --max-request-bytes is not given. A request carries a whole
// conversation, so the figure sits well above a long agent turn.
const DefaultMaxRequestBytes int64 = 64 << 20

// LoopbackListen is where `--loopback` binds the gateway: the default port on
// loopback, the safe bind a local-only gateway wants — one that Listen's token
// check accepts without a token.
Expand Down Expand Up @@ -68,6 +73,10 @@ type Options struct {
Log *slog.Logger
// Now is the clock the reading cache ages against; nil uses time.Now.
Now func() time.Time
// MaxRequestBytes is the largest completion request body the gateway
// accepts; a larger one is answered 413. Zero or less uses
// DefaultMaxRequestBytes.
MaxRequestBytes int64
}

// Handler is the gateway: the fleet it serves, the token its callers present,
Expand All @@ -80,6 +89,8 @@ type Handler struct {
log *slog.Logger
now func() time.Time

maxRequestBytes int64

mu sync.Mutex
results []fleet.NodeResult
at time.Time
Expand All @@ -99,7 +110,11 @@ func New(cfg *fleet.Config, token string, opts Options) *Handler {
if now == nil {
now = time.Now
}
return &Handler{cfg: cfg, token: token, cfgFor: opts.ConfigFor, log: log, now: now}
maxBytes := opts.MaxRequestBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxRequestBytes
}
return &Handler{cfg: cfg, token: token, cfgFor: opts.ConfigFor, log: log, now: now, maxRequestBytes: maxBytes}
}

// Listen opens the gateway's listener, applying the daemon's exposure rule:
Expand Down Expand Up @@ -430,8 +445,15 @@ func (h *Handler) reading(ctx context.Context) []fleet.NodeResult {
// handleCompletion routes a completion request to the node serving its model,
// waking one when nothing is and the fleet file allows it.
func (h *Handler) handleCompletion(w http.ResponseWriter, r *http.Request) {
model, body, err := requestModel(r)
model, body, err := requestModel(w, r, h.maxRequestBytes)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeError(w, http.StatusRequestEntityTooLarge, fmt.Errorf(
"the request body is larger than the gateway's limit of %d bytes: raise it with --max-request-bytes",
tooLarge.Limit))
return
}
writeError(w, http.StatusBadRequest, err)
return
}
Expand Down Expand Up @@ -471,10 +493,13 @@ func (h *Handler) handleCompletion(w http.ResponseWriter, r *http.Request) {
}

// requestModel pulls the model field out of a completion request and returns
// it with the full body, which the proxy must forward unmodified. A body that
// is not a JSON object fails saying so, rather than being routed at a guess.
func requestModel(r *http.Request) (string, []byte, error) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
// it with the full body, which the proxy must forward unmodified. A body
// larger than limit returns an error wrapping *http.MaxBytesError, and any
// other failed read returns an error, so a body that was not read in full is
// never returned. A body that is not a JSON object fails saying so, rather
// than being routed at a guess.
func requestModel(w http.ResponseWriter, r *http.Request, limit int64) (string, []byte, error) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
if err != nil {
return "", nil, fmt.Errorf("reading the request: %w", err)
}
Expand Down
102 changes: 102 additions & 0 deletions internal/gateway/gateway_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ type fakeNode struct {
pushedKey string
// engineGotAuth is the last authorisation the engine itself saw.
engineGotAuth string
// engineGotBody is the last request body the engine itself read.
engineGotBody string
// statusHits counts status calls, so a burst's fan-out is countable.
statusHits int

Expand Down Expand Up @@ -135,6 +137,9 @@ func (f *fakeNode) engineHandler() http.Handler {
return
}
body, _ := io.ReadAll(r.Body)
f.mu.Lock()
f.engineGotBody = string(body)
f.mu.Unlock()
switch {
case strings.Contains(string(body), `"stream":true`):
w.Header().Set("Content-Type", "text/event-stream")
Expand Down Expand Up @@ -1476,3 +1481,100 @@ func TestStartingEngineThatNeverAnswersFailsNamingTheNode(t *testing.T) {
t.Errorf("the caller should not be given a dial error: %s", body)
}
}

// paddedRequest is a valid completion request of exactly size bytes.
func paddedRequest(size int) string {
const head = `{"model":"org/wanted","messages":[{"role":"user","content":"`
const tail = `"}]}`
return head + strings.Repeat("a", size-len(head)-len(tail)) + tail
}

func TestRequestAtTheLimitIsForwardedInFull(t *testing.T) {
node := newFakeNode(t, string(daemon.StateRunning), "org/wanted")
h := New(fleetOf(t, []string{"box"}, node), "", Options{MaxRequestBytes: 4096})
req := paddedRequest(4096)

resp, body := post(t, h, "", req)
if resp.StatusCode != http.StatusOK {
t.Fatalf("HTTP %d, body %s", resp.StatusCode, body)
}
node.mu.Lock()
defer node.mu.Unlock()
if node.engineGotBody != req {
t.Errorf("the engine read %d bytes, want the full %d", len(node.engineGotBody), len(req))
}
}

func TestRequestOverTheLimitIsRefusedWith413(t *testing.T) {
node := newFakeNode(t, string(daemon.StateRunning), "org/wanted")
h := New(fleetOf(t, []string{"box"}, node), "", Options{MaxRequestBytes: 4096})

resp, body := post(t, h, "", paddedRequest(4097))
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("HTTP %d, want 413; body %s", resp.StatusCode, body)
}
for _, want := range []string{"4096", "--max-request-bytes"} {
if !strings.Contains(body, want) {
t.Errorf("the refusal should name %q: %s", want, body)
}
}
if strings.Contains(body, "not a JSON body") {
t.Errorf("an over-limit body must not read as malformed JSON: %s", body)
}
node.mu.Lock()
defer node.mu.Unlock()
if node.statusHits != 0 || node.engineGotBody != "" {
t.Error("an over-limit request reached a node")
}
}

func TestDefaultRequestLimitAdmitsWhatOneMiBRefused(t *testing.T) {
node := newFakeNode(t, string(daemon.StateRunning), "org/wanted")
h := New(fleetOf(t, []string{"box"}, node), "", Options{})

resp, body := post(t, h, "", paddedRequest(2<<20))
if resp.StatusCode != http.StatusOK {
t.Fatalf("HTTP %d, body %s", resp.StatusCode, body)
}
}

// failingBody yields some bytes and then a read error, the way a client that
// drops its connection part-way through a body does.
type failingBody struct{ sent bool }

func (b *failingBody) Read(p []byte) (int, error) {
if b.sent {
return 0, fmt.Errorf("connection reset")
}
b.sent = true
return copy(p, `{"model":"org/wanted","messages":[`), nil
}

func TestPartiallyReadRequestIsNeverForwarded(t *testing.T) {
node := newFakeNode(t, string(daemon.StateRunning), "org/wanted")
h := New(fleetOf(t, []string{"box"}, node), "", Options{})
req := httptest.NewRequest(http.MethodPost, "http://gw/v1/chat/completions", &failingBody{})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("HTTP %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "reading the request") {
t.Errorf("the refusal should say the read failed: %s", rec.Body.String())
}
node.mu.Lock()
defer node.mu.Unlock()
if node.statusHits != 0 || node.engineGotBody != "" {
t.Error("a partly read request reached a node")
}
}

func TestMalformedJSONStillReadsAsMalformed(t *testing.T) {
node := newFakeNode(t, string(daemon.StateRunning), "org/wanted")
h := New(fleetOf(t, []string{"box"}, node), "", Options{})
resp, body := post(t, h, "", `{"model":`)
if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "not a JSON body") {
t.Fatalf("HTTP %d, body %s; want 400 naming malformed JSON", resp.StatusCode, body)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-05
Loading
Loading