Guide contents

Lesson 71 of 73

Workshop: loop gateway

This workshop builds the whole gateway in one Maven project and runs it. The earlier lessons each taught one piece: Spring Boot Essentials for an LLM Gateway, An OpenAI-Compatible API in Front of llama-server, Streaming Responses Through the Gateway, Loading, Unloading, and Scheduling Models, and Securing and Observing the Gateway. Here the pieces become one program.

A gateway is a server that sits between clients and the real engines. Clients talk to one address and one API, and the gateway decides which engine answers, starts the engine if needed, and stops it when nobody uses it. The result here is loop gateway, a command that serves /v1/models, /v1/chat/completions (with streaming), and the Ollama-style /api/tags and /api/ps. Ollama is another local-model tool with its own HTTP API, and /api/tags and /api/ps follow its shape. Behind these endpoints, engines start on demand and stop when idle.

A reader finishes with a gateway that integration tests check against fake engines, that loop chat and loop agent can use, and that runs in front of the real llama-server. The table lists the stages of the build and the example for each.

Stage Builds Example
Project layout Dependencies, classes, and where each lesson's work lives 1
Settings GatewayProperties with validation and the application.yml 2
Backends Backend, RemoteBackend, ProcessBackend 3
Backend manager Lease, load on first request, eviction, idle unload 4
Listing endpoints /v1/models, /api/tags, /api/ps 5
Chat proxy Non-streaming /v1/chat/completions with pass-through and error mapping 6
Streaming StreamingResponseBody and the flush setting 7
API keys ApiKeyFilter in a Spring Security filter chain 8
Actuator Health with loaded engines, a gauge, and a counter 9
loop gateway The picocli command and the packaged jar 10
Test harness A fake engine process and a @SpringBootTest 11
Stream and disconnect tests No buffering, and the lease released after a client leaves 12
Lifecycle and error tests Eviction, idle unload, error shapes, a crashing engine 13
loop chat The Project 11 ChatClient through the gateway 14
loop agent The Project 21 model adapter through the gateway 15
Real run llama-server with the tiny model behind the gateway 16
Compatibility check curl requests shaped like an OpenAI SDK 17
Extension tasks What to add next, with the place to change 18

The project uses Spring Boot 4.1.1 (Spring Framework 7.0.9, Spring Security 7, Jackson 3, Tomcat) on JDK 25. All Spring code in this lesson compiled and ran in that project. Spring code blocks use the fence flag java,ignore because the course's single-file checker cannot run Spring. The output blocks are copied from real runs of Maven, the jar, and curl.

The engines in the examples are of two kinds. The first kind is fake OpenAI-compatible processes: a small Java class, started with the same java binary. A fake answers instantly and predictably, so tests can check exact results. The second kind is the real llama-server from the pinned llama.cpp commit, with the course's tiny model, which has random weights. The text that this model generates is meaningless. The protocol, the token counts, and the log lines are real. Ports in the examples are fixed numbers chosen for the run (18770 to 18772), because the gateway's own port is a command-line option.

Example 1: the project layout and what each lesson contributes

Before any code, it helps to know which classes make up the gateway and which earlier lesson each class comes from. The project has one pom.xml (the Maven file that lists dependencies and build settings), twelve main classes, and a test directory with a fake engine and one integration test class.

XML
<parent>
  <groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>4.1.1</version>
</parent>
<properties><java.version>25</java.version></properties>
<dependencies>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
  <dependency><groupId>info.picocli</groupId><artifactId>picocli</artifactId><version>4.7.7</version></dependency>
  <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin>
  <groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId>
  <configuration><mainClass>dev.loop.gateway.GatewayCommand</mainClass></configuration>
</plugin></plugins></build>
Output
src/main/java/dev/loop/gateway/ApiKeyFilter.java
src/main/java/dev/loop/gateway/Backend.java
src/main/java/dev/loop/gateway/BackendManager.java
src/main/java/dev/loop/gateway/ChatController.java
src/main/java/dev/loop/gateway/EnginesHealth.java
src/main/java/dev/loop/gateway/ErrorAdvice.java
src/main/java/dev/loop/gateway/GatewayApp.java
src/main/java/dev/loop/gateway/GatewayCommand.java
src/main/java/dev/loop/gateway/GatewayException.java
src/main/java/dev/loop/gateway/GatewayProperties.java
src/main/java/dev/loop/gateway/ModelsController.java
src/main/java/dev/loop/gateway/SecurityConfig.java
src/main/resources/application.yml
src/main/resources/spring.properties
src/test/java/dev/loop/gateway/FakeEngine.java
src/test/java/dev/loop/gateway/GatewayTest.java

The output is the file listing of the finished project (find src -type f). The dependencies in the pom.xml are:

  • the web starter (Spring MVC on Tomcat),
  • Actuator (health and metrics endpoints),
  • Security (the filter chain for API keys),
  • validation (checks on settings),
  • picocli (command-line parsing).

There is no reactive starter. The gateway is Spring MVC on virtual threads, so each request, including a long streamed one, occupies a cheap virtual thread and not an expensive platform thread. The Spring Boot Maven plugin's mainClass points at the picocli command, so java -jar starts loop gateway and not a bare Spring application.

The table maps each class to its responsibility and to the earlier lesson that taught it. Use it as an index when a later example refers to a class.

Class Responsibility Lesson
GatewayApp, GatewayProperties, application.yml Application class, typed validated settings, defaults Spring Boot Essentials
ModelsController, ChatController, ErrorAdvice, GatewayException The HTTP surface and error mapping OpenAI-compatible API
ChatController (the StreamingResponseBody part), spring.properties Streaming without buffering Streaming proxy
Backend, BackendManager Engines as child processes, load on demand, eviction, idle unload Model lifecycle, with the Backend shape from Workshop: loop serve, run, and chat
ApiKeyFilter, SecurityConfig, EnginesHealth, metrics Keys, health, metrics Securing and observing
GatewayCommand The loop gateway command picocli Basics

Example 2: settings with validation and a YAML file

The gateway needs to know which models it serves. Each model is served either by an existing URL or by a command that starts an engine. The gateway also needs an idle timeout, a limit on loaded models, and API keys. A @ConfigurationProperties record holds all of these, and Bean Validation annotations (@NotBlank, @Min, @NotEmpty) reject a wrong file at startup instead of at the first request. Failing at startup is better because the operator sees the mistake immediately.

Java
package dev.loop.gateway;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.time.Duration;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;

@Validated
@ConfigurationProperties("loop.gateway")
public record GatewayProperties(
        @DefaultValue List<String> apiKeys,
        @DefaultValue("5m") Duration idleTimeout,
        @DefaultValue("60s") Duration startTimeout,
        @DefaultValue("1") @Min(1) int maxLoaded,
        @NotEmpty List<@Valid ModelSpec> models) {

    /** One model the gateway serves: a remote server (url) or a command to start (command, with {port}). */
    public record ModelSpec(@NotBlank String name, String url, List<String> command) {
        public ModelSpec {
            if ((url == null) == (command == null || command.isEmpty())) {
                throw new IllegalArgumentException("model " + name + ": set exactly one of url and command");
            }
        }
    }
}
YAML
# application.yml (defaults packaged in the jar)
spring:
  application.name: loop-gateway
  threads.virtual.enabled: true
  mvc.async.request-timeout: 10m
management:
  endpoints.web.exposure.include: health,metrics
  endpoint.health.show-details: always
loop.gateway:
  api-keys: []
  idle-timeout: 5m
  max-loaded: 1

The packaged application.yml holds only defaults. A second file, passed with --config, lists the models. This one starts two fake engines (the class is from Example 11, compiled into a directory of its own). The {port} in each command is a placeholder that the gateway replaces with a free port:

YAML
# fake.yml
loop.gateway:
  api-keys: [sk-local-1]
  idle-timeout: 8s
  tick: 2s
  max-loaded: 1
  models:
    - name: alpha
      command: [java, -cp, /tmp/claude-0/gw70/fake, dev.loop.gateway.FakeEngine, "{port}", alpha]
    - name: beta
      command: [java, -cp, /tmp/claude-0/gw70/fake, dev.loop.gateway.FakeEngine, "{port}", beta]

Two invalid files show the validation. The first sets both url and command for a model, which the ModelSpec constructor rejects, because a model needs exactly one way to be reached. The second sets max-loaded: 0, which @Min(1) rejects, because a gateway that may load zero models can serve nothing. Both runs stop before the port opens.

Output
Failed to bind properties under 'loop.gateway.models[0]' to dev.loop.gateway.GatewayProperties$ModelSpec:
    Reason: java.lang.IllegalArgumentException: model alpha: set exactly one of url and command
Output
Binding to target dev.loop.gateway.GatewayProperties failed:

    Property: loop.gateway.maxLoaded
    Value: "0"
    Origin: URL [file:/tmp/claude-0/gw70/bad2.yml] - 2:15
    Reason: must be greater than or equal to 1

The record has defaults through @DefaultValue, so a file that only lists models is valid. idle-timeout and start-timeout are Duration values, so 5m, 8s, and 120s bind directly without any parsing code. api-keys defaults to an empty list, which Example 8 defines as "no key check". The placeholder {port} in a command is replaced by the gateway with the free port it picks (Example 3).

The failure messages come from Spring Boot's failure analyzer. They name the property and the line in the file where the value was set (2:15 means line 2, column 15), so the operator knows what to fix. The second message prints the property as loop.gateway.maxLoaded because that is the name of the record component.

The tick property is not in the record. It is the interval at which the gateway checks for idle models. BackendManager reads it through a placeholder (${loop.gateway.tick:5s}, where 5s is the default). This keeps the scheduling interval out of the validated settings and still lets a test set it.

Example 3: backends that start and stop engines

A backend is the object that provides an engine's base URL and makes sure the engine is ready. There are two kinds. A remote backend points at a server that already runs, and the gateway never starts or stops it. A process backend starts the engine as a child process (a program launched and controlled by the gateway). The interface and the process-based implementation are the Project 12 design from Workshop: loop serve, run, and chat, cut down to what the gateway needs:

  • a free port,
  • a command with {port} replaced,
  • a reader thread that keeps the last log line,
  • health polling with backoff (waiting longer between attempts),
  • a process-tree kill on close.
Java
package dev.loop.gateway;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.ConnectException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.List;

/** An engine the gateway forwards to. Same shape as the Backend of Project 12. */
public interface Backend extends AutoCloseable {
    String baseUrl();                       // ends with /v1
    void ensureReady() throws Exception;    // start if needed, then wait until healthy
    @Override void close();                 // stop only what this backend started

    /** A server that already runs. */
    record RemoteBackend(String baseUrl) implements Backend {
        public void ensureReady() { }
        public void close() { }
    }

    /** An engine started as a child process on a free port; the command contains {port}. */
    final class ProcessBackend implements Backend {
        private final String name;
        private final List<String> command;
        private final Duration startTimeout;
        private final int port;
        private final ArrayDeque<String> log = new ArrayDeque<>();
        private Process process;

        public ProcessBackend(String name, List<String> command, Duration startTimeout) {
            this.name = name;
            this.startTimeout = startTimeout;
            this.port = freePort();
            this.command = command.stream().map(a -> a.replace("{port}", Integer.toString(port))).toList();
        }

        public String baseUrl() { return "http://127.0.0.1:" + port + "/v1"; }

        public void ensureReady() throws Exception {
            ProcessBuilder pb = new ProcessBuilder(command).redirectErrorStream(true);
            pb.environment().remove("JAVA_TOOL_OPTIONS");
            process = pb.start();
            Thread.startVirtualThread(() -> {
                try { process.inputReader().lines().forEach(this::remember); }
                catch (UncheckedIOException e) { /* the pipe closed because the engine stopped */ }
            });
            HttpClient http = HttpClient.newHttpClient();
            HttpRequest health = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/health"))
                    .timeout(Duration.ofSeconds(5)).build();
            long deadline = System.nanoTime() + startTimeout.toNanos();
            long sleep = 50;
            while (true) {
                if (!process.isAlive()) {
                    throw new IllegalStateException("engine " + name + " exited with code " + process.exitValue()
                            + " during startup: " + lastLog());
                }
                try {
                    if (http.send(health, HttpResponse.BodyHandlers.discarding()).statusCode() == 200) return;
                } catch (ConnectException e) {
                    // not listening yet
                }
                if (System.nanoTime() > deadline) {
                    close();
                    throw new IllegalStateException("engine " + name + " not ready after " + startTimeout);
                }
                Thread.sleep(sleep);
                sleep = Math.min(sleep * 2, 1000);
            }
        }

        public void close() {
            if (process == null) return;
            process.descendants().forEach(ProcessHandle::destroy);
            process.destroy();
            try { process.waitFor(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }

        private synchronized void remember(String line) { if (log.size() == 20) log.removeFirst(); log.addLast(line); }
        private synchronized String lastLog() { return log.isEmpty() ? "(no output)" : log.peekLast(); }

        private static int freePort() {
            try (ServerSocket s = new ServerSocket(0, 0, InetAddress.getLoopbackAddress())) { return s.getLocalPort(); }
            catch (IOException e) { throw new IllegalStateException(e); }
        }
    }
}

A model with a url becomes a RemoteBackend. A model with a command becomes a ProcessBackend. ensureReady() starts the child process, then polls /health. The wait between attempts starts at 50 ms and doubles (100 ms, 200 ms, and so on) up to 1 s. Polling stops in one of three ways:

  • the engine answers 200, which means ready;
  • the process exits, and the exception carries the exit code and the last log line;
  • the start timeout passes, the engine is stopped, and the exception says so.

llama-server answers /health with 503 while the model loads, so the loop treats any status other than 200 as "not ready yet".

The reader thread exists because a child process writes to a pipe. If nobody reads the pipe, the process can block when the pipe is full, and the gateway would also have no record of why an engine failed. The thread reads each line and keeps the last 20.

The reader thread catches UncheckedIOException. When the gateway stops an engine, the pipe closes while the reader is blocked. Without the catch, the virtual thread would print a stack trace for a normal shutdown. The JAVA_TOOL_OPTIONS variable is removed from the child's environment so that JVM-based fake engines do not print the "Picked up" line into the captured log.

Example 4: the backend manager

The manager decides which engines run. A lease is the right to use an engine. A request takes a lease before it talks to the engine and gives it back afterwards. The manager keeps a count of leases per model, and that count is how it knows whether a model is busy.

The manager hands out a lease for each request, starts an engine on the first lease for a model, stops the least recently used idle engine when the limit is reached, and stops engines that were idle longer than the timeout. It also publishes the loaded count as a Micrometer gauge (a metric that shows a current value).

Java
package dev.loop.gateway;

import dev.loop.gateway.GatewayProperties.ModelSpec;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import jakarta.annotation.PreDestroy;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class BackendManager {
    private static final Logger log = LoggerFactory.getLogger(BackendManager.class);

    /** What a request holds while it uses an engine; closing it releases the engine. */
    public record Lease(String model, String baseUrl, Runnable onClose) implements AutoCloseable {
        @Override public void close() { onClose.run(); }
    }

    public record LoadedModel(String name, int active, Instant lastUsed) {}

    private static final class Entry {
        final CompletableFuture<Backend> ready = new CompletableFuture<>();
        int active;
        Instant lastUsed;
    }

    private final GatewayProperties props;
    private final Clock clock;
    private final Map<String, Entry> entries = new LinkedHashMap<>();

    public BackendManager(GatewayProperties props, Clock clock, MeterRegistry metrics) {
        this.props = props;
        this.clock = clock;
        Gauge.builder("loop.gateway.models.loaded", this, m -> m.loaded().size()).register(metrics);
    }

    public List<ModelSpec> configured() { return props.models(); }

    public Lease acquire(String model) {
        Entry entry;
        boolean starter = false;
        ModelSpec spec = props.models().stream().filter(m -> m.name().equals(model)).findFirst()
                .orElseThrow(() -> new GatewayException(404, "model_not_found", "The model '" + model + "' does not exist"));
        synchronized (this) {
            entry = entries.get(model);
            if (entry == null) {
                makeRoom();
                entry = new Entry();
                entries.put(model, entry);
                starter = true;
            }
            entry.active++;
            entry.lastUsed = clock.instant();
        }
        if (starter) load(spec, entry);
        try {
            Backend backend = entry.ready.join();
            Entry held = entry;
            return new Lease(model, backend.baseUrl(), () -> release(held));
        } catch (CompletionException e) {
            release(entry);
            throw e.getCause() instanceof GatewayException g ? g
                    : new GatewayException(503, "engine_unavailable", e.getCause().getMessage());
        }
    }

    private void load(ModelSpec spec, Entry entry) {
        log.info("loading model {}", spec.name());
        Backend backend = spec.url() != null ? new Backend.RemoteBackend(spec.url())
                : new Backend.ProcessBackend(spec.name(), spec.command(), props.startTimeout());
        try {
            backend.ensureReady();
            entry.ready.complete(backend);
        } catch (Exception e) {
            backend.close();
            synchronized (this) { entries.remove(spec.name(), entry); }
            entry.ready.completeExceptionally(e);
        }
    }

    private synchronized void release(Entry entry) {
        entry.active--;
        entry.lastUsed = clock.instant();
    }

    /** Called with the lock held: free a slot by stopping the least recently used idle model. */
    private void makeRoom() {
        if (entries.size() < props.maxLoaded()) return;
        Map.Entry<String, Entry> victim = entries.entrySet().stream()
                .filter(e -> e.getValue().active == 0 && e.getValue().ready.isDone())
                .min(Comparator.comparing(e -> e.getValue().lastUsed)).orElseThrow(() ->
                        new GatewayException(503, "models_busy", "All " + props.maxLoaded() + " model slots are in use"));
        log.info("evicting model {}", victim.getKey());
        entries.remove(victim.getKey());
        victim.getValue().ready.join().close();
    }

    @Scheduled(fixedDelayString = "${loop.gateway.tick:5s}")
    public void unloadIdle() { unloadIdle(clock.instant()); }

    public synchronized void unloadIdle(Instant now) {
        for (var it = entries.entrySet().iterator(); it.hasNext(); ) {
            var e = it.next();
            Entry entry = e.getValue();
            if (entry.ready.isDone() && entry.active == 0
                    && !entry.lastUsed.plus(props.idleTimeout()).isAfter(now)) {
                log.info("unloading idle model {}", e.getKey());
                it.remove();
                entry.ready.join().close();
            }
        }
    }

    public synchronized List<LoadedModel> loaded() {
        List<LoadedModel> out = new ArrayList<>();
        entries.forEach((name, e) -> { if (e.ready.isDone()) out.add(new LoadedModel(name, e.active, e.lastUsed)); });
        return out;
    }

    public Duration idleTimeout() { return props.idleTimeout(); }

    @PreDestroy
    public synchronized void shutdown() {
        entries.values().forEach(e -> { if (e.ready.isDone() && !e.ready.isCompletedExceptionally()) e.ready.join().close(); });
        entries.clear();
    }
}

The design follows from four rules. The diagram shows what acquire does for each request, and the list explains each rule.

Text
acquire("X")
  |
  +-- entry for X exists? --yes--> active++ ; wait on its future ; return lease
  |
  no
  |
  makeRoom()   at max-loaded: stop the least recently used idle model,
  |            or fail with 503 models_busy when none is idle
  |
  create entry (active = 1) ; start the engine OUTSIDE the lock ; return lease
  1. One entry per model, shared by all requests. An Entry holds a CompletableFuture<Backend> (a value that will be available later), a count of active requests, and the time of last use. The first request for a model creates the entry and loads the engine on its own thread. Later requests for the same model find the entry and wait on the same future, so a model is never started twice.
  2. Loading does not hold the lock. acquire changes the maps inside synchronized, then releases the lock before load. Starting an engine can take many seconds. A request for a loaded model therefore does not wait behind a slow load of another model.
  3. Eviction needs an idle victim. To evict a model means to stop it to free a slot. makeRoom picks the entry with the oldest lastUsed among entries with no active request and a finished load. This is the least recently used (LRU) rule. When every loaded model is busy, it throws a 503 models_busy error. This is the policy from Loading, Unloading, and Scheduling Models with the queue left out. Example 18 lists the queue as an extension.
  4. Time comes from a Clock. The scheduled method calls unloadIdle(clock.instant()), and tests call unloadIdle(Instant) with a chosen time instead of sleeping for minutes.

Lease implements AutoCloseable, so a controller releases an engine with try-with-resources, and @PreDestroy stops every engine when the application shuts down. The scheduled method needs @EnableScheduling, which is on the application class GatewayApp:

Java
package dev.loop.gateway;

import java.time.Clock;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.security.autoconfigure.UserDetailsServiceAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@EnableConfigurationProperties(GatewayProperties.class)
@EnableScheduling
public class GatewayApp {
    @Bean Clock clock() { return Clock.systemUTC(); }
}

The application class also excludes UserDetailsServiceAutoConfiguration. Without the exclusion, Spring Boot creates a default user with a generated password and prints it at startup, although the gateway authenticates with API keys only.

The log lines below come from the running gateway during the curl session of Examples 5 to 9 (idle timeout 8 s, tick 2 s, one loaded model at most). They tell the story in order: alpha loads on its first request, beta evicts it because only one model may be loaded, and beta is unloaded after it sat idle.

Output
dev.loop.gateway.BackendManager          : loading model alpha
dev.loop.gateway.BackendManager          : evicting model alpha
dev.loop.gateway.BackendManager          : loading model beta
dev.loop.gateway.BackendManager          : unloading idle model beta

Example 5: /v1/models, /api/tags, and /api/ps

The listing endpoints let clients discover what the gateway offers. They answer from the configuration and the manager. /v1/models has the OpenAI shape (object: "list" and a data array). /api/tags has the Ollama shape for installed models. /api/ps lists the models that are loaded now, with the time at which each will be unloaded.

Java
package dev.loop.gateway;

import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ModelsController {
    private final BackendManager manager;

    public ModelsController(BackendManager manager) { this.manager = manager; }

    record ModelObject(String id, String object, long created, String owned_by) {}
    record ModelList(String object, List<ModelObject> data) {}

    @GetMapping("/v1/models")
    ModelList models() {
        return new ModelList("list", manager.configured().stream()
                .map(m -> new ModelObject(m.name(), "model", 0, "loop")).toList());
    }

    @GetMapping("/api/tags")
    Map<String, Object> tags() {
        return Map.of("models", manager.configured().stream()
                .map(m -> Map.of("name", m.name(), "model", m.name())).toList());
    }

    @GetMapping("/api/ps")
    Map<String, Object> ps() {
        return Map.of("models", manager.loaded().stream()
                .map(m -> Map.of("name", m.name(), "model", m.name(),
                        "expires_at", m.lastUsed().plus(manager.idleTimeout()).toString())).toList());
    }
}

The record components of ModelObject are named owned_by in snake case on purpose, because Jackson writes record component names as they are and the OpenAI field is owned_by. The gateway answers /api/tags with name and model only, and /api/ps with name, model, and expires_at. Ollama's responses carry more fields (digest, size, details). Clients that need those fields are outside this workshop. The commands below call the three endpoints right after startup:

Terminal
B=localhost:18770; H='Authorization: Bearer sk-local-1'
curl -s $B/v1/models -H "$H" | jq -c .
curl -s $B/api/tags -H "$H" | jq -c .
curl -s $B/api/ps -H "$H"
Output
{"object":"list","data":[{"id":"alpha","object":"model","created":0,"owned_by":"loop"},{"id":"beta","object":"model","created":0,"owned_by":"loop"}]}
{"models":[{"name":"alpha","model":"alpha"},{"name":"beta","model":"beta"}]}
{"models":[]}

The first two lines list the two configured models. The third line shows that nothing is loaded yet: listing models never starts an engine, because a listing does not need one. created is 0 because the gateway has no download time to report.

Example 6: the non-streaming chat proxy

/v1/chat/completions is the main endpoint. It reads the model field, leases the engine, forwards the request body to the engine, and returns the engine's answer with its status. Three decisions keep the gateway compatible with whatever the engine and the client agree on. They are listed after the code.

Java
package dev.loop.gateway;

import io.micrometer.core.instrument.MeterRegistry;
import java.io.InputStream;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

@RestController
public class ChatController {
    private final BackendManager manager;
    private final MeterRegistry metrics;
    private final JsonMapper json;
    private final HttpClient http = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(5)).build();

    public ChatController(BackendManager manager, MeterRegistry metrics, JsonMapper json) {
        this.manager = manager;
        this.metrics = metrics;
        this.json = json;
    }

    @PostMapping("/v1/chat/completions")
    ResponseEntity<StreamingResponseBody> chat(@RequestBody byte[] raw) throws Exception {
        JsonNode body = json.readTree(raw);
        String model = body.path("model").asString("");
        if (model.isEmpty()) throw new GatewayException(400, "invalid_request_error", "The field 'model' is required");
        if (!body.path("messages").isArray() || body.path("messages").isEmpty()) {
            throw new GatewayException(400, "invalid_request_error", "The field 'messages' must be a non-empty array");
        }
        boolean stream = body.path("stream").asBoolean(false);

        BackendManager.Lease lease = manager.acquire(model);          // unknown models fail here
        metrics.counter("loop.gateway.requests", "model", model, "stream", Boolean.toString(stream)).increment();
        boolean handedOver = false;
        try {
            HttpRequest upstream = HttpRequest.newBuilder(URI.create(lease.baseUrl() + "/chat/completions"))
                    .timeout(Duration.ofMinutes(10)).header("Content-Type", "application/json")
                    .POST(HttpRequest.BodyPublishers.ofByteArray(raw)).build();   // the body goes through unchanged
            HttpResponse<InputStream> res = http.send(upstream, HttpResponse.BodyHandlers.ofInputStream());
            boolean sse = res.statusCode() == 200 && stream;
            if (!sse) {
                try (InputStream in = res.body()) {
                    byte[] answer = in.readAllBytes();
                    return ResponseEntity.status(res.statusCode()).contentType(MediaType.APPLICATION_JSON)
                            .body(out -> out.write(answer));
                }
            }
            StreamingResponseBody pump = out -> {
                try (lease; InputStream in = res.body()) {      // closing the stream cancels the upstream request
                    byte[] buf = new byte[8192];
                    for (int n = in.read(buf); n != -1; n = in.read(buf)) {
                        out.write(buf, 0, n);
                        out.flush();
                    }
                }
            };
            handedOver = true;
            return ResponseEntity.ok().contentType(MediaType.TEXT_EVENT_STREAM)
                    .cacheControl(CacheControl.noCache()).header("X-Accel-Buffering", "no").body(pump);
        } catch (ConnectException e) {
            throw new GatewayException(502, "engine_unreachable", "The engine for '" + model + "' refused the connection");
        } catch (HttpTimeoutException e) {
            throw new GatewayException(504, "engine_timeout", "The engine for '" + model + "' did not answer in time");
        } finally {
            if (!handedOver) lease.close();
        }
    }
}
  • The body goes through unchanged. The controller reads the body as byte[], parses a copy with Jackson to find model, messages, and stream, and sends the original bytes upstream. Fields the gateway does not know (top_k, tools, response_format, stream_options, vendor extensions) reach the engine untouched. The response comes back untouched too, including llama-server's timings object and system_fingerprint.
  • Upstream errors keep their status and body. An engine answer with status 400 or 500 is returned as it is, so a client sees llama-server's exceed_context_size_error (Example 17) with its fields.
  • Gateway errors use the OpenAI shape. The few failures that the gateway itself detects are GatewayException values. ErrorAdvice turns them into {"error":{"message":...,"type":...}}, which is the shape that OpenAI client libraries parse. The two classes are:
Java
package dev.loop.gateway;

/** An error the gateway reports in the OpenAI error shape. */
public class GatewayException extends RuntimeException {
    private final int status;
    private final String type;

    public GatewayException(int status, String type, String message) {
        super(message);
        this.status = status;
        this.type = type;
    }
    public int status() { return status; }
    public String type() { return type; }
}
Java
package dev.loop.gateway;

import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
public class ErrorAdvice {
    static Map<String, Object> body(String type, String message) {
        return Map.of("error", Map.of("message", message, "type", type));
    }

    @ExceptionHandler(GatewayException.class)
    ResponseEntity<Map<String, Object>> gateway(GatewayException e) {
        return ResponseEntity.status(e.status()).body(body(e.type(), e.getMessage()));
    }

    @ExceptionHandler({HttpMessageNotReadableException.class, tools.jackson.core.JacksonException.class})
    ResponseEntity<Map<String, Object>> unreadable(Exception e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body("invalid_request_error", "The request body is not valid JSON"));
    }
}

ErrorAdvice also handles tools.jackson.core.JacksonException, which is what Jackson 3 throws for malformed JSON. Jackson is the Java JSON library. In Jackson 3 the packages moved from com.fasterxml.jackson to tools.jackson (annotations keep the old package), and exceptions are unchecked. Spring Boot 4.1.1 provides a JsonMapper bean from tools.jackson.databind.json, which the controller receives through its constructor. The accessor for text became asString().

The gateway maps ConnectException to 502 (bad gateway: the engine refused the connection) and HttpTimeoutException to 504 (gateway timeout: the engine did not answer in time).

The metrics counter is incremented after acquire succeeds, so an unknown model name never becomes a tag value. A client that sends random model names would otherwise create one time series per name, and the registry would grow without limit. The commands send a normal request, list the loaded models, and then send four requests that fail in different ways:

Terminal
B=localhost:18770; H='Authorization: Bearer sk-local-1'
curl -s $B/v1/chat/completions -H "$H" -H 'Content-Type: application/json' \
  -d '{"model":"alpha","messages":[{"role":"user","content":"hello"}]}' | jq -c .
curl -s $B/api/ps -H "$H" | jq -c .
curl -s -w ' [%{http_code}]\n' $B/v1/chat/completions -H "$H" -H 'Content-Type: application/json' \
  -d '{"model":"nope","messages":[{"role":"user","content":"x"}]}'
curl -s -w ' [%{http_code}]\n' $B/v1/chat/completions -H "$H" -H 'Content-Type: application/json' -d '{"messages":[]}'
curl -s -w ' [%{http_code}]\n' $B/v1/chat/completions -H "$H" -H 'Content-Type: application/json' \
  -d '{"model":"alpha","messages":[{"role":"user","content":"boom"}]}'
Output
{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"fake alpha: hello"}}],"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}}
{"models":[{"expires_at":"2026-10-03T16:07:01.402811121Z","model":"alpha","name":"alpha"}]}
{"error":{"message":"The model 'nope' does not exist","type":"model_not_found"}} [404]
{"error":{"message":"The field 'model' is required","type":"invalid_request_error"}} [400]
{"error":{"code":500,"message":"engine exploded","type":"server_error"}} [500]

The first line is the fake engine's answer, forwarded as it came. The second line shows that the request loaded alpha (it was absent in Example 5) and that it will be unloaded at the time in expires_at, which is the last use plus the idle timeout of 8 s (the time stamp differs on every run). Lines 3 and 4 are gateway errors in the OpenAI shape: an unknown model and a missing field. Line 5 is an engine error. The fake answers 500 with its own body, and the gateway returns both unchanged.

Example 7: streaming without buffering

A streamed answer must reach the client while the engine is still generating it. Otherwise the user waits for the whole answer and sees nothing, which defeats streaming. The controller returns a StreamingResponseBody that copies the upstream byte stream to the client and flushes after every read. (To flush means to push buffered bytes to the network now.) The lease is closed in the finally of that copy, so the engine stays "active" for the whole stream. Closing the upstream stream when the copy ends (or fails) cancels the request to the engine.

The streaming branch is in the controller of Example 6. Two problems appeared while it was built. The first version returned ResponseEntity<?> and failed with No converter for [class ChatController$$Lambda...] with preset Content-Type 'text/event-stream'. Spring selects the streaming return-value handler from the generic type of the method's declared return type, and a wildcard hides it. The method now returns ResponseEntity<StreamingResponseBody> for both cases, and the non-streaming branch wraps its bytes in a lambda.

The second problem was buffering. With the code as it stood, every event of a 250 ms stream arrived at the client at the end. The reason is in Spring Framework 7.0.9: ServletServerHttpResponse.getBody() wraps the servlet output stream with StreamUtils.nonFlushing unless the flag spring.http.response.flush.enabled is set. The out.flush() call inside a StreamingResponseBody then does nothing. The flag is read through SpringProperties, which loads a spring.properties file from the class path root:

PROPERTIES
# src/main/resources/spring.properties
spring.http.response.flush.enabled=true

The other ways to flush are to write to HttpServletResponse.getOutputStream() directly or to use SseEmitter, which flushes after each event. The measurement below uses the same jar twice, once with spring.properties removed from it (zip -d). Each request streams four events from a fake engine that waits 60 ms between events. curl reports when the first byte arrived (time_starttransfer) and when the response ended (time_total).

Terminal
curl -sN -o /dev/null -w 'first byte %{time_starttransfer}s, total %{time_total}s\n' localhost:$PORT/v1/chat/completions \
  -H 'Content-Type: application/json' -d '{"model":"alpha","stream":true,"messages":[{"role":"user","content":"hi"}]}'
Output
without the flag (port 18773):
first byte 0.257334s, total 0.261292s
first byte 0.251795s, total 0.254884s
with the flag (port 18771):
first byte 0.014301s, total 0.259864s
first byte 0.009146s, total 0.254662s

Without the flag, the first byte arrives together with the last one, after about 250 ms (4 events times 60 ms is 240 ms, plus a little). With the flag, the first byte arrives after about 10 ms and the rest follow every 60 ms until the end. The total time is the same in both runs. The flag changes when the client can start reading, and not how long the engine takes.

The same stream through curl -N (no output buffering in curl) shows the SSE events of the engine unchanged. SSE (server-sent events) is the format of a streamed answer: lines that start with data:, separated by blank lines. The output includes the usage chunk that stream_options requested and the final [DONE]:

Terminal
curl -sN localhost:18770/v1/chat/completions -H 'Authorization: Bearer sk-local-1' -H 'Content-Type: application/json' \
  -d '{"model":"alpha","stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":"hello"}]}'
Output
data: {"choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"content":"fake "},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"content":"alpha "},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"content":"says "},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{"content":"hello "},"finish_reason":null}]}

data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}}

data: [DONE]

The response headers are Content-Type: text/event-stream, Cache-Control: no-cache, and X-Accel-Buffering: no. The last header tells nginx (when it is used as a reverse proxy in front of the gateway) not to buffer the response. spring.mvc.async.request-timeout: 10m in application.yml raises the asynchronous request timeout, which would otherwise end a long generation.

Example 8: API keys in a Spring Security filter chain

The gateway accepts Authorization: Bearer <key> for keys listed in loop.gateway.api-keys. The filter does the check, and a SecurityFilterChain decides which paths need it. An empty key list turns the check off, which suits a gateway bound to 127.0.0.1 and clients without a key option (Example 15). Securing and Observing the Gateway explains the filter chain concepts in detail. This version is a cut-down form with one role and plain keys in the file.

Java
package dev.loop.gateway;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

/** Accepts "Authorization: Bearer <key>" when the key is configured; an empty key list turns the check off. */
public class ApiKeyFilter extends OncePerRequestFilter {
    private final List<byte[]> keys;

    public ApiKeyFilter(List<String> keys) {
        this.keys = keys.stream().map(k -> k.getBytes(StandardCharsets.UTF_8)).toList();
    }

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        String header = req.getHeader("Authorization");
        boolean ok = keys.isEmpty() || (header != null && header.startsWith("Bearer ") && matches(header.substring(7)));
        if (!ok) {
            res.setStatus(401);
            res.setContentType(MediaType.APPLICATION_JSON_VALUE);
            res.setHeader("WWW-Authenticate", "Bearer");
            res.getWriter().write("{\"error\":{\"message\":\"Incorrect API key provided\",\"type\":\"invalid_api_key\"}}");
            return;
        }
        SecurityContextHolder.getContext().setAuthentication(UsernamePasswordAuthenticationToken.authenticated(
                "client", null, List.of(new SimpleGrantedAuthority("ROLE_API"))));
        chain.doFilter(req, res);
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest req) {
        return req.getRequestURI().startsWith("/actuator/health");     // the chain permits this path
    }

    private boolean matches(String presented) {
        byte[] p = presented.getBytes(StandardCharsets.UTF_8);
        boolean found = false;
        for (byte[] k : keys) found |= MessageDigest.isEqual(k, p);     // constant time per key
        return found;
    }
}
Java
package dev.loop.gateway;

import jakarta.servlet.DispatcherType;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain chain(HttpSecurity http, GatewayProperties props) throws Exception {
        return http
                .csrf(c -> c.disable())
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(a -> a
                        .dispatcherTypeMatchers(DispatcherType.ASYNC, DispatcherType.ERROR).permitAll()
                        .requestMatchers("/actuator/health", "/actuator/health/**").permitAll()
                        .anyRequest().authenticated())
                .addFilterBefore(new ApiKeyFilter(props.apiKeys()), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

Four details matter. Each one answers a question that a reader may have about the two classes above.

  • Constant-time comparison. MessageDigest.isEqual takes the same time for every position of the first difference, and the loop compares against every configured key without stopping at the first match. A timing measurement therefore reveals nothing about the keys.
  • Stateless chain. CSRF protection (which protects browser sessions) and sessions are off, because every request carries its key.
  • Health stays open. /actuator/health is permitted and skipped by the filter, so a process manager or load balancer can probe it without a key. Every other path, including the rest of Actuator, requires an authenticated request.
  • The asynchronous dispatch is permitted. A StreamingResponseBody completes in a second servlet dispatch of type ASYNC. The OncePerRequestFilter does not run again for it, and the stateless chain has no stored authentication. Without dispatcherTypeMatchers(ASYNC, ERROR).permitAll(), the authorization rules can reject that dispatch after the response has started.
Terminal
curl -s -i localhost:18770/v1/models | head -1
curl -s localhost:18770/v1/models
curl -s -o /dev/null -w '%{http_code}\n' localhost:18770/actuator/health
curl -s -o /dev/null -w '%{http_code}\n' localhost:18770/actuator/metrics
Output
HTTP/1.1 401 
{"error":{"message":"Incorrect API key provided","type":"invalid_api_key"}}
200
401

The four commands show the result. A request without a key gets 401 with the same error shape and type name (invalid_api_key) that OpenAI uses, which the ChatClient of Example 14 turns into an ApiException. Health answers 200 without a key, and the metrics endpoint answers 401.

Example 9: Actuator health and metrics

Actuator provides /actuator/health and /actuator/metrics. The gateway adds three things: a health indicator named engines that reports how many models are configured and which are loaded, a gauge for the loaded count, and a counter for requests tagged by model and by streaming mode. (A tag is a label on a metric, such as model=alpha, that lets a query filter the numbers.)

Java
package dev.loop.gateway;

import java.util.List;
import org.springframework.boot.health.contributor.Health;
import org.springframework.boot.health.contributor.HealthIndicator;
import org.springframework.stereotype.Component;

/** UP while the gateway runs; the details list the engines that are loaded now. */
@Component("engines")
public class EnginesHealth implements HealthIndicator {
    private final BackendManager manager;

    public EnginesHealth(BackendManager manager) { this.manager = manager; }

    @Override
    public Health health() {
        List<String> loaded = manager.loaded().stream().map(BackendManager.LoadedModel::name).toList();
        return Health.up().withDetail("configured", manager.configured().size()).withDetail("loaded", loaded).build();
    }
}

The indicator returns UP at all times. A gateway with no loaded engine is healthy, because engines load on demand. A loaded engine that has died is replaced on the next request. The gauge is registered in the BackendManager constructor and the counter in ChatController (Examples 4 and 6). In Spring Boot 4 the health classes are in org.springframework.boot.health.contributor. The commands read the health answer and the two meters:

Terminal
curl -s localhost:18770/actuator/health | jq -c '{status, engines: .components.engines}'
curl -s localhost:18770/actuator/metrics/loop.gateway.models.loaded -H 'Authorization: Bearer sk-local-1' | jq -c '{name, measurements}'
curl -s localhost:18770/actuator/metrics/loop.gateway.requests -H 'Authorization: Bearer sk-local-1' | jq -c '{name, measurements, availableTags}'
Output
{"status":"UP","engines":{"details":{"configured":2,"loaded":["alpha"]},"status":"UP"}}
{"name":"loop.gateway.models.loaded","measurements":[{"statistic":"VALUE","value":1.0}]}
{"name":"loop.gateway.requests","measurements":[{"statistic":"COUNT","value":4.0}],"availableTags":[{"tag":"stream","values":["true","false"]},{"tag":"model","values":["nope","alpha"]}]}

The first line shows UP with details: two models configured and alpha loaded. The second line shows the gauge at 1.0, one loaded model. The third line is the request counter.

The counter output in this capture was taken before the tag fix described in Example 6, so nope appears among the model tags and the count is 4. With the fix in the shown code, an unknown model is rejected before the counter and no longer appears. The show-details: always setting makes the details visible to unauthenticated callers of /actuator/health. The details contain model names only. A gateway on a shared network sets when-authorized or never instead.

Example 10: the loop gateway command and the jar

loop gateway is a picocli command that turns its options into Spring Boot arguments and starts the application. picocli is the command-line parsing library of the course. This is the same command style as loop serve in Workshop: loop serve, run, and chat.

Java
package dev.loop.gateway;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import org.springframework.boot.SpringApplication;
import picocli.CommandLine;
import picocli.CommandLine.Command;
import picocli.CommandLine.Option;

@Command(name = "gateway", mixinStandardHelpOptions = true, version = "loop gateway 0.1.0",
        description = "Serve the OpenAI-compatible and Ollama-style API in front of the configured engines.")
public class GatewayCommand implements Callable<Integer> {
    @Option(names = "--config", paramLabel = "FILE", description = "YAML file with the loop.gateway settings.")
    String config;

    @Option(names = {"-p", "--port"}, defaultValue = "11435", description = "Port to listen on (default: ${DEFAULT-VALUE}).")
    int port;

    @Option(names = "--host", defaultValue = "127.0.0.1", description = "Address to bind (default: ${DEFAULT-VALUE}).")
    String host;

    @Override
    public Integer call() {
        List<String> args = new ArrayList<>(List.of("--server.port=" + port, "--server.address=" + host));
        if (config != null) args.add("--spring.config.additional-location=optional:file:" + config);
        SpringApplication.run(GatewayApp.class, args.toArray(String[]::new));
        return 0;
    }

    public static void main(String[] args) {
        int code = new CommandLine(new GatewayCommand()).execute(args);
        if (code != 0) System.exit(code);       // on success the server keeps the JVM alive
    }
}

Each option maps to a Spring Boot argument: --port becomes --server.port, --host becomes --server.address, and --config becomes an additional Spring configuration location, so the file's values override application.yml.

The default address is 127.0.0.1, so the gateway accepts only local connections unless the user passes another address. The default port 11435 is Ollama's default port (11434 in envconfig/config.go of Ollama v0.35.1) plus one, chosen so that both can run on one machine. The command calls System.exit only for a non-zero code, because a started Spring application keeps the JVM alive on its own. The commands build the jar, print the help, and start the gateway with the fake configuration:

Terminal
mvn -q -B package
java -jar target/loop-gateway-0.1.0.jar --help
java -jar target/loop-gateway-0.1.0.jar --config fake.yml --port 18770
Output
Usage: gateway [-hV] [--config=FILE] [--host=<host>] [-p=<port>]
Serve the OpenAI-compatible and Ollama-style API in front of the configured
engines.
      --config=FILE   YAML file with the loop.gateway settings.
  -h, --help          Show this help message and exit.
      --host=<host>   Address to bind (default: 127.0.0.1).
  -p, --port=<port>   Port to listen on (default: 11435).
  -V, --version       Print version information and exit.

The help text in the output is generated by picocli from the annotations. The packaged jar is about 27 MB (Tomcat, Spring, Security, Actuator, and Micrometer). mvn package runs the integration tests of the next three examples before it builds the jar, and -DskipTests skips them. The Spring Boot Maven plugin's repackage goal, which the starter parent binds to the package phase, produces an executable jar with its dependencies nested inside.

Example 11: the test harness with a fake engine

Integration tests check the gateway the way a client uses it: over HTTP, against engines that run as separate processes. A test that uses a mock object inside the same JVM cannot show process start, health polling, crashes, or kills. The fake engine is a small main class that listens on a port given as its first argument. It answers /health with 200 and answers chat requests the way an OpenAI-compatible server does. Its behaviors are:

  • It echoes the last message for ordinary requests.
  • It returns a tool call when the request has tools.
  • It streams slowly when stream is true.
  • It answers 500 for the message boom.
  • It keeps the last request body at /last, so that a test can see what the gateway forwarded.
  • With the third argument broken, it prints llama-server's error for a missing model file and exits with code 1.
Java
package dev.loop.gateway;

import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/** A fake OpenAI-compatible engine: java -cp ... dev.loop.gateway.FakeEngine PORT NAME [broken]. */
public class FakeEngine {
    static final Pattern CONTENT = Pattern.compile("\"content\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"");

    public static void main(String[] args) throws Exception {
        int port = Integer.parseInt(args[0]);
        String name = args[1];
        if (args.length > 2 && args[2].equals("broken")) {
            System.out.println("llama_model_load: error loading model: failed to open " + name + ".gguf");
            System.exit(1);
        }
        AtomicReference<String> last = new AtomicReference<>("");
        HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0);
        server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
        server.createContext("/health", ex -> send(ex, 200, "{\"status\":\"ok\"}"));
        server.createContext("/last", ex -> send(ex, 200, last.get()));
        server.createContext("/v1/chat/completions", ex -> {
            String body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
            last.set(body);
            String prompt = lastContent(body);
            if (prompt.equals("boom")) {
                send(ex, 500, "{\"error\":{\"code\":500,\"message\":\"engine exploded\",\"type\":\"server_error\"}}");
            } else if (body.contains("\"stream\":true")) {
                stream(ex, name, prompt, body.contains("\"tools\""));
            } else if (body.contains("\"tools\"")) {
                send(ex, 200, "{\"choices\":[{\"index\":0,\"finish_reason\":\"tool_calls\",\"message\":{\"role\":\"assistant\",\"content\":\"\","
                        + "\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\\\"path\\\":\\\"pom.xml\\\"}\"}}]}}],"
                        + "\"usage\":{\"prompt_tokens\":20,\"completion_tokens\":8,\"total_tokens\":28}}");
            } else {
                send(ex, 200, "{\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"message\":{\"role\":\"assistant\",\"content\":\"fake "
                        + name + ": " + prompt + "\"}}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":4,\"total_tokens\":14}}");
            }
        });
        server.start();
        System.out.println("fake engine " + name + " ready");
    }

    static String lastContent(String body) {
        Matcher m = CONTENT.matcher(body);
        String found = "";
        while (m.find()) found = m.group(1);
        return found;
    }

    static void stream(HttpExchange ex, String name, String prompt, boolean tools) throws IOException {
        ex.getResponseHeaders().set("Content-Type", "text/event-stream");
        ex.sendResponseHeaders(200, 0);
        int count = prompt.equals("long") ? 1000 : 4;
        List<String> pieces = new java.util.ArrayList<>();
        for (String w : ("fake " + name + " says " + prompt).split(" ")) pieces.add(w + " ");
        try (OutputStream out = ex.getResponseBody()) {
            event(out, "{\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null},\"finish_reason\":null}]}");
            for (int i = 0; i < count; i++) {
                String piece = prompt.equals("long") ? "tok" + i + " " : pieces.get(i % pieces.size());
                event(out, "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\"" + piece + "\"},\"finish_reason\":null}]}");
                Thread.sleep(60);
            }
            event(out, "{\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}");
            event(out, "{\"choices\":[],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":" + count + ",\"total_tokens\":" + (10 + count) + "}}");
            event(out, "[DONE]");
        } catch (IOException | InterruptedException e) {
            // the gateway closed the connection
        }
    }

    static void event(OutputStream out, String data) throws IOException {
        out.write(("data: " + data + "\n\n").getBytes(StandardCharsets.UTF_8));
        out.flush();
    }

    static void send(HttpExchange ex, int status, String body) throws IOException {
        byte[] b = body.getBytes(StandardCharsets.UTF_8);
        ex.getResponseHeaders().set("Content-Type", "application/json");
        ex.sendResponseHeaders(status, b.length);
        try (OutputStream out = ex.getResponseBody()) { out.write(b); }
    }
}

The fake's JDK HttpServer uses a virtual-thread executor. Without an executor, the server handles one exchange at a time, and two streams in parallel would run one after the other, which hides concurrency bugs in the gateway.

The test class starts the whole application on a random port (RANDOM_PORT), so tests never collide with a running gateway. A @DynamicPropertySource method defines three models whose commands start the fake with the test's own class path, so no jar or file has to be prepared. The command property is written as one comma-separated string, which Spring Boot binds to a list. The class also has small helpers (get, post, chat, user) that build HTTP requests.

Java
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class GatewayTest {
    static final String KEY = "sk-test";

    @DynamicPropertySource
    static void engines(DynamicPropertyRegistry r) {
        String java = ProcessHandle.current().info().command().orElse("java");
        String cp = System.getProperty("java.class.path");
        r.add("loop.gateway.api-keys[0]", () -> KEY);
        r.add("loop.gateway.max-loaded", () -> 1);
        r.add("loop.gateway.start-timeout", () -> "30s");
        r.add("loop.gateway.tick", () -> "1h");
        String[] names = {"alpha", "beta", "broken"};
        for (int i = 0; i < names.length; i++) {
            String n = names[i], mode = n.equals("broken") ? "broken" : "ok";
            r.add("loop.gateway.models[" + i + "].name", () -> n);
            r.add("loop.gateway.models[" + i + "].command", () -> String.join(",", java, "-cp", cp,
                    "dev.loop.gateway.FakeEngine", "{port}", n, mode));
        }
    }

    @LocalServerPort int port;
    @Autowired BackendManager manager;
    final HttpClient http = HttpClient.newHttpClient();

    @BeforeEach
    void unloadEverything() { manager.unloadIdle(Instant.now().plus(Duration.ofDays(1))); }

    HttpResponse<String> get(String path, String key) throws Exception {
        HttpRequest.Builder b = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path));
        if (key != null) b.header("Authorization", "Bearer " + key);
        return http.send(b.build(), HttpResponse.BodyHandlers.ofString());
    }

    HttpRequest.Builder post(String json) {
        return HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + "/v1/chat/completions"))
                .header("Authorization", "Bearer " + KEY).header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(json));
    }

    HttpResponse<String> chat(String json) throws Exception {
        return http.send(post(json).build(), HttpResponse.BodyHandlers.ofString());
    }

    static String user(String model, String text, String extra) {
        return "{\"model\":\"" + model + "\",\"messages\":[{\"role\":\"user\",\"content\":\"" + text + "\"}]" + extra + "}";
    }

unloadEverything runs before each test and calls unloadIdle with a time one day ahead, so no test depends on the engines that a previous test left loaded. The first four tests check the listing, the keys, load on demand, and pass-through:

Java
    @Test
    void modelsComeFromTheConfiguration() throws Exception {
        assertThat(get("/v1/models", KEY).body()).contains("\"id\":\"alpha\"", "\"id\":\"beta\"");
        assertThat(get("/api/tags", KEY).body()).contains("\"name\":\"alpha\"");
    }
    @Test
    void keysAreChecked() throws Exception {
        assertThat(get("/v1/models", null).statusCode()).isEqualTo(401);
        assertThat(get("/v1/models", "wrong").body()).contains("invalid_api_key");
        assertThat(get("/actuator/health", null).statusCode()).isEqualTo(200);
        assertThat(get("/actuator/metrics", null).statusCode()).isEqualTo(401);
    }
    @Test
    void firstRequestLoadsTheEngine() throws Exception {
        assertThat(get("/api/ps", KEY).body()).isEqualTo("{\"models\":[]}");
        HttpResponse<String> res = chat(user("alpha", "hello", ""));
        assertThat(res.statusCode()).isEqualTo(200);
        assertThat(res.body()).contains("fake alpha: hello");
        assertThat(get("/api/ps", KEY).body()).contains("\"name\":\"alpha\"");
    }
    @Test
    void unknownFieldsAndToolsPassThrough() throws Exception {
        String res = chat(user("alpha", "hi", ",\"top_k\":7,\"custom_field\":{\"a\":1},\"tools\":[{\"type\":\"function\"}]")).body();
        assertThat(res).contains("\"tool_calls\"", "read_file");
        try (BackendManager.Lease lease = manager.acquire("alpha")) {
            String seen = http.send(HttpRequest.newBuilder(URI.create(lease.baseUrl().replace("/v1", "/last"))).build(),
                    HttpResponse.BodyHandlers.ofString()).body();
            assertThat(seen).contains("\"top_k\":7", "\"custom_field\":{\"a\":1}");
        }
    }

The pass-through test sends fields that no gateway class knows (top_k, custom_field, tools) and then reads /last from the engine through a lease on the same backend. If the gateway had dropped a field, /last would not contain it. The command below runs the whole test class. The test names follow in the order of the report (JUnit does not promise an order):

Terminal
mvn -B test
Output
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 12.55 s -- in dev.loop.gateway.GatewayTest
[INFO] Tests run: 10, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

The Maven summary shows 10 tests with no failures. The ten tests are modelsComeFromTheConfiguration, keysAreChecked, firstRequestLoadsTheEngine, unknownFieldsAndToolsPassThrough, streamingArrivesWhileTheEngineIsStillWriting, aClientThatDisconnectsReleasesTheEngine, anotherModelEvictsTheIdleOne, idleModelsAreUnloaded, errorsHaveTheOpenAiShape, and actuatorShowsLoadedEnginesAndMetrics. The elapsed time is the machine's and varies. It includes starting a JVM for each fake engine.

Example 12: tests for streaming and for a client that leaves

Two properties of the streaming path break without an error message: events must arrive while the engine is still writing, and a client that disconnects must release its engine. A test is the only reliable way to notice if either one breaks. The first test reads the response line by line, records the arrival time of every data: line, and requires the events to be spread over at least 150 ms. The fake waits 60 ms between its four content events, so an unbuffered response spreads over about 240 ms, and a buffered one arrives in one block.

Java
    @Test
    void streamingArrivesWhileTheEngineIsStillWriting() throws Exception {
        HttpResponse<java.io.InputStream> res = http.send(post(user("alpha", "stream me", ",\"stream\":true")).build(),
                HttpResponse.BodyHandlers.ofInputStream());
        assertThat(res.headers().firstValue("Content-Type")).hasValue("text/event-stream");
        List<Long> arrivals = new ArrayList<>();
        List<String> data = new ArrayList<>();
        long start = System.nanoTime();
        try (BufferedReader in = new BufferedReader(new InputStreamReader(res.body(), StandardCharsets.UTF_8))) {
            for (String line; (line = in.readLine()) != null; ) {
                if (line.startsWith("data:")) { data.add(line); arrivals.add((System.nanoTime() - start) / 1_000_000); }
            }
        }
        assertThat(data.getLast()).isEqualTo("data: [DONE]");
        assertThat(data).anyMatch(l -> l.contains("\"usage\""));
        long spread = arrivals.getLast() - arrivals.get(1);
        assertThat(spread).as("events are spread over time, not delivered as one block").isGreaterThanOrEqualTo(150);
    }

This test failed on the first run of the project (all eight data: lines arrived in the same millisecond), which is how the flush problem of Example 7 was found. A test with a timing assertion is what turns a silent buffering problem into a visible failure.

The second test starts a stream of 1,000 events (the message long), reads three events, and closes the client's stream. The engine's active count is 1 while the stream is open. After the close, the gateway's next write to the client fails with an IOException, the try (lease; in) block ends, the upstream connection closes, and the active count returns to 0. The test polls for up to five seconds, because the release happens on another thread.

Java
    @Test
    void aClientThatDisconnectsReleasesTheEngine() throws Exception {
        HttpResponse<java.io.InputStream> res = http.send(post(user("alpha", "long", ",\"stream\":true")).build(),
                HttpResponse.BodyHandlers.ofInputStream());
        BufferedReader in = new BufferedReader(new InputStreamReader(res.body(), StandardCharsets.UTF_8));
        for (int seen = 0; seen < 3; ) if (in.readLine().startsWith("data:")) seen++;
        assertThat(manager.loaded().getFirst().active()).isEqualTo(1);
        in.close();
        long deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos();
        while (manager.loaded().getFirst().active() != 0 && System.nanoTime() < deadline) Thread.sleep(50);
        assertThat(manager.loaded().getFirst().active()).isZero();
    }

If the lease were not released, the engine would count as busy forever, never be unloaded, and block eviction. With max-loaded: 1, every request for another model would then fail with models_busy. The detection depends on a write: the gateway notices the departure at the next event it forwards. An engine that is silent for a long time therefore keeps its lease until it produces output or times out.

Example 13: tests for eviction, idle unload, and errors

The lifecycle tests use the manager directly where a time source is needed. anotherModelEvictsTheIdleOne sends one request to each of two models with max-loaded: 1 and checks that /api/ps lists only the second. idleModelsAreUnloaded passes explicit times to unloadIdle. One minute later the model (idle timeout 5 minutes) stays, and six minutes later it is gone. No test waits for real time to pass.

Java
    @Test
    void anotherModelEvictsTheIdleOne() throws Exception {
        chat(user("alpha", "one", ""));
        chat(user("beta", "two", ""));
        String ps = get("/api/ps", KEY).body();
        assertThat(ps).contains("\"name\":\"beta\"").doesNotContain("alpha");
    }
    @Test
    void idleModelsAreUnloaded() throws Exception {
        chat(user("alpha", "one", ""));
        manager.unloadIdle(Instant.now().plus(Duration.ofMinutes(1)));
        assertThat(manager.loaded()).hasSize(1);
        manager.unloadIdle(Instant.now().plus(Duration.ofMinutes(6)));
        assertThat(manager.loaded()).isEmpty();
    }

errorsHaveTheOpenAiShape covers the status and body of every error path: an unknown model (404), a missing field (400), malformed JSON (400), an engine answer of 500 that passes through, and an engine that exits during startup (503 with the exit code and the last log line).

Java
    @Test
    void errorsHaveTheOpenAiShape() throws Exception {
        assertThat(chat(user("nope", "x", "")).statusCode()).isEqualTo(404);
        assertThat(chat(user("nope", "x", "")).body()).contains("model_not_found");
        assertThat(chat("{\"messages\":[]}").body()).contains("The field 'model' is required");
        assertThat(chat("{not json").statusCode()).isEqualTo(400);
        HttpResponse<String> boom = chat(user("alpha", "boom", ""));
        assertThat(boom.statusCode()).isEqualTo(500);
        assertThat(boom.body()).contains("engine exploded");
        HttpResponse<String> broken = chat(user("broken", "x", ""));
        assertThat(broken.statusCode()).isEqualTo(503);
        assertThat(broken.body()).contains("exited with code 1", "failed to open broken.gguf");
    }

The crashing engine can also be seen from outside. The gateway was started with the broken model in the configuration. It answers with the engine's own last log line and leaves nothing loaded:

Terminal
curl -s -w ' [%{http_code}]\n' localhost:18771/v1/chat/completions -H 'Content-Type: application/json' \
  -d '{"model":"broken","messages":[{"role":"user","content":"x"}]}'
curl -s localhost:18771/api/ps
Output
{"error":{"message":"engine broken exited with code 1 during startup: llama_model_load: error loading model: failed to open broken.gguf","type":"engine_unavailable"}} [503]
{"models":[]}

The empty list has a second cause that the output does not show. alpha was loaded before this request, and makeRoom stopped it to make room for broken before the load failed. A failed load therefore costs the idle model that was evicted. Example 18 lists the fix: start the new engine first when memory allows, or evict after a successful load.

Example 14: pointing loop chat at the gateway

loop chat uses the ChatClient of Project 11 (The Chat Completions Protocol and Streaming in Java). The only change for the gateway is its base URL and key. The program below copies the ChatClient, Message, Request, Result, Listener, Cancel, and ApiException classes unchanged from that lesson (the part between its Project 11 markers). It needs Jackson 2, com.fasterxml.jackson.databind, on the class path. The program adds a main that sends one request with a wrong key and one with the right key.

Java
public class GatewayChat {
    // ChatClient, Message, Request, Result, Usage, Listener, Cancel, ApiException:
    // the library of Project 11, unchanged.

    public static void main(String[] args) throws Exception {
        String baseUrl = args[0], key = args[1], model = args[2];
        try (ChatClient bad = new ChatClient(baseUrl, "wrong-key")) {
            bad.chat(new Request(model, List.of(Message.user("hi")), null, null, null));
        } catch (ApiException e) {
            System.out.println("wrong key: " + e.status + " " + e.type);
        }
        try (ChatClient client = new ChatClient(baseUrl, key)) {
            List<Message> history = new ArrayList<>(List.of(Message.system("You are a coding assistant."), Message.user("Say hello.")));
            StringBuilder shown = new StringBuilder();
            Result result = client.stream(new Request(model, history, 0.0, args.length > 3 ? Integer.parseInt(args[3]) : null, null),
                    piece -> shown.append('|').append(piece), new Cancel());
            System.out.println("deltas: " + shown + "|");
            System.out.println("finish=" + result.finishReason() + " " + result.usage());
        }
    }
}
Terminal
java -cp "clients/out:.java-libs/*" GatewayChat http://127.0.0.1:18770/v1 sk-local-1 alpha
Output
wrong key: 401 invalid_api_key
deltas: |fake |alpha |says |Say |
finish=stop Usage[promptTokens=10, completionTokens=4, totalTokens=14]

The first line shows that the gateway's 401 body parses with the client's existing error code: ApiException.error reads error.type, which is invalid_api_key. The second line shows the listener receiving four deltas through the gateway, with the | marks that the listener printed between them. The fake engine builds its text from the last message of the request, which is Say hello., and its four events carry the first four words of fake alpha says Say hello..

The third line shows finish=stop and the usage that the client requested with include_usage: the gateway forwarded stream_options and the engine's usage chunk. loop chat takes the base URL from its options, so the change for those commands is the URL and the key that they pass to that constructor.

Example 15: pointing loop agent at the gateway

loop agent (Workshop: loop agent) talks to its engine through the OpenAiModel adapter (a class that translates between the agent and a model API), which sends tools and reads tool_calls. The program below keeps that class unchanged and calls it twice with the same request: through a gateway without keys (port 18771) and through the gateway with a key (port 18770). The fake engine answers a request that has tools with a read_file call.

Java
public class GatewayAgent {
    // sealed interface Message and its records, ToolSpec, ModelResponse, ModelClient, OpenAiModel:
    // the adapter of the coding-agent lesson (Example 2), unchanged.

    public static void main(String[] args) throws Exception {
        ModelClient client = new OpenAiModel(args[0], args[1]);
        List<ToolSpec> tools = List.of(new ToolSpec("read_file", "Read a file in the workspace.",
                JSON.readTree("{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}")));
        List<Message> history = new ArrayList<>(List.of(new SystemMessage("You are loop, a coding agent."), new UserMessage("Read pom.xml.")));
        ModelResponse r = client.chat(history, tools);
        if (r.message().toolCalls().isEmpty()) {
            System.out.println("text answer: [" + r.message().text() + "] tokens=" + r.promptTokens() + "+" + r.completionTokens());
            return;
        }
        ToolCall call = r.message().toolCalls().get(0);
        System.out.println("tool call: " + call.name() + " id=" + call.id() + " args=" + JSON.readTree(call.arguments())
                + " tokens=" + r.promptTokens() + "+" + r.completionTokens());
    }
}
Terminal
java -cp "clients/out:.java-libs/*" GatewayAgent http://127.0.0.1:18771/v1 alpha
java -cp "clients/out:.java-libs/*" GatewayAgent http://127.0.0.1:18770/v1 alpha
Output
tool call: read_file id=call_1 args={"path":"pom.xml"} tokens=20+8
text answer: [] tokens=0+0

The first call went through the gateway and returned the engine's tool call with its id, its arguments, and its token counts. The tools array and the tool_calls reply therefore crossed the gateway unchanged.

The second call shows what happens when the gateway requires a key and the adapter has none to send. The gateway answers 401 with an error body. The adapter does not check the status, finds no choices, and returns an empty text answer with zero tokens. The agent receives neither a tool call nor text.

Two changes belong to the agent: a --api-key option (or an environment variable) that adds the Authorization header, and a status check that throws on an error response. Until then, a gateway used by loop agent runs with an empty key list on 127.0.0.1.

Example 16: a real run in front of llama-server

The same gateway runs with the real engine by changing the model entry to a llama-server command. The configuration starts the pinned build with the tiny random-weight model, a 2,048-token context window, the Qwen2.5 chat template, and --jinja, which makes llama-server parse tool calls (Function Calling from First Principles). The idle timeout is 20 s so that the run shows the unload.

YAML
# real.yml (paths shortened: $LLAMA is the llama.cpp directory, $TINY the tiny model)
loop.gateway:
  api-keys: [sk-local-1]
  idle-timeout: 20s
  tick: 2s
  start-timeout: 120s
  max-loaded: 1
  models:
    - name: tiny
      command:
        - $LLAMA/build/bin/llama-server
        - -m
        - $TINY/tiny-random-qwen2vocab-f32.gguf
        - --host
        - 127.0.0.1
        - --port
        - "{port}"
        - -c
        - "2048"
        - --alias
        - tiny
        - --no-webui
        - --jinja
        - --chat-template-file
        - $LLAMA/models/templates/Qwen-Qwen2.5-7B-Instruct.jinja
Terminal
java -jar target/loop-gateway-0.1.0.jar --config real.yml --port 18772
curl -s localhost:18772/api/ps -H 'Authorization: Bearer sk-local-1'
curl -s localhost:18772/v1/chat/completions -H 'Authorization: Bearer sk-local-1' -H 'Content-Type: application/json' \
  -d '{"model":"tiny","max_tokens":6,"temperature":0,"messages":[{"role":"user","content":"Say hello."}]}' \
  | jq -c '{model, finish: .choices[0].finish_reason, usage, has_timings: has("timings"), keys: keys}'
curl -s localhost:18772/api/ps -H 'Authorization: Bearer sk-local-1' | jq -c .
Output
{"models":[]}
{"model":"tiny","finish":"length","usage":{"completion_tokens":6,"prompt_tokens":32,"total_tokens":38,"prompt_tokens_details":{"cached_tokens":0}},"has_timings":true,"keys":["choices","created","id","model","object","system_fingerprint","timings","usage"]}
{"models":[{"model":"tiny","expires_at":"2026-10-03T16:08:33.449500421Z","name":"tiny"}]}

Read the output in order. Before the first request nothing is loaded. The chat request started the process on a free port, waited for /health to answer 200, and returned the engine's answer in about one second for this small model, load included. (A 7B model takes seconds to tens of seconds to load, and the first request waits for that.)

The answer has llama-server's own fields: timings, system_fingerprint, and prompt_tokens_details, which the gateway did not parse or remove. finish is length because max_tokens was 6 and the model had not finished. The tiny model's text is meaningless and is not shown. The last line shows the loaded model with its expiry time.

The GatewayChat program of Example 14 runs against the same gateway (loop chat would use the same URL). Its deltas are random tokens, so the text is printed only to show that streaming works end to end. The counts are the real part:

Terminal
java -Dstdout.encoding=UTF-8 -cp "clients/out:.java-libs/*" GatewayChat http://127.0.0.1:18772/v1 sk-local-1 tiny 8
Output
wrong key: 401 invalid_api_key
deltas: |תחר|ContentAlignment|ContentAlignment|ContentAlignment|ContentAlignment|ContentAlignment|">

|">

|
finish=length Usage[promptTokens=22, completionTokens=8, totalTokens=30]

The usage line comes from llama-server: 22 prompt tokens (the system message, the user message, and the template's markers) and 8 completion tokens, the limit set by the program's fourth argument. Then the gateway should unload the engine after the idle timeout. The commands below wait 25 seconds without requests, list the loaded models, and count the llama-server processes with the alias tiny:

Terminal
sleep 25
curl -s localhost:18772/api/ps -H 'Authorization: Bearer sk-local-1'
ps -eo args | grep -c "[l]lama-server.*--alias tiny"
Output
{"models":[]}
0

The first line is the empty model list, and the second line is the process count: none is left. The gateway's log has one loading model tiny line and one unloading idle model tiny line. After the gateway itself was stopped with kill, no engine process remained, because the manager's @PreDestroy hook stops every loaded engine.

Example 17: an OpenAI-compatibility check with curl

OpenAI client libraries (SDKs) send a bearer key, a JSON body with fields such as n, user, seed, and stream_options, and extra client headers. They expect specific fields in the answer. An SDK is not needed to check this. curl can send the same request and jq (a command-line JSON tool) can check the fields. The check runs against the real engine of Example 16, with the variables commonly used to configure OpenAI client libraries (OPENAI_BASE_URL, OPENAI_API_KEY).

Terminal
export OPENAI_BASE_URL=http://127.0.0.1:18772/v1 OPENAI_API_KEY=sk-local-1
curl -s $OPENAI_BASE_URL/models -H "Authorization: Bearer $OPENAI_API_KEY" -H 'User-Agent: OpenAI/Python 1.99.0' \
  | jq -c '{object, ids: [.data[] | {id, object}]}'
curl -s $OPENAI_BASE_URL/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY" -H 'Content-Type: application/json' \
  -H 'User-Agent: OpenAI/Python 1.99.0' -H 'X-Stainless-Lang: python' \
  -d '{"model":"tiny","messages":[{"role":"user","content":"Say hello."}],"max_tokens":4,"temperature":0,"n":1,"user":"u-1","seed":7}' \
  | jq -c '{object, model, finish: .choices[0].finish_reason, role: .choices[0].message.role, usage: (.usage|{prompt_tokens,completion_tokens,total_tokens})}'
Output
{"object":"list","ids":[{"id":"tiny","object":"model"}]}
{"object":"chat.completion","model":"tiny","finish":"length","role":"assistant","usage":{"prompt_tokens":32,"completion_tokens":4,"total_tokens":36}}

The model list has the shape of OpenAI's (object: "list", entries with id and object: "model"), which is what an SDK's models.list() and a UI's model picker read. The completion has object: "chat.completion", the model name, a finish_reason, the assistant role, and the three usage counts. The fields n, user, seed, and the extra headers passed through the gateway without an error.

The streaming form comes next, reduced with jq to the fields that a client reads:

Terminal
curl -sN $OPENAI_BASE_URL/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY" -H 'Content-Type: application/json' \
  -d '{"model":"tiny","stream":true,"stream_options":{"include_usage":true},"messages":[{"role":"user","content":"Say hello."}],"max_tokens":3,"temperature":0}' \
  | grep '^data:' | while IFS= read -r l; do d=${l#data: }; if [ "$d" = "[DONE]" ]; then echo "[DONE]"; \
    else echo "$d" | jq -c '{object, delta: .choices[0].delta, finish: .choices[0].finish_reason, usage: (.usage // null | if . then {prompt_tokens, completion_tokens, total_tokens} else null end)}'; fi; done
Output
{"object":"chat.completion.chunk","delta":{"role":"assistant","content":null},"finish":null,"usage":null}
{"object":"chat.completion.chunk","delta":{"content":"Fine"},"finish":null,"usage":null}
{"object":"chat.completion.chunk","delta":{"content":" spiele"},"finish":null,"usage":null}
{"object":"chat.completion.chunk","delta":{"content":" spiele"},"finish":null,"usage":null}
{"object":"chat.completion.chunk","delta":{},"finish":"length","usage":null}
{"object":"chat.completion.chunk","delta":null,"finish":null,"usage":{"prompt_tokens":32,"completion_tokens":3,"total_tokens":35}}
[DONE]

The stream follows the format that an SDK parses:

  • a first chunk with the role,
  • one chunk per content delta,
  • a chunk with the finish_reason,
  • a final chunk with an empty choices array (shown as delta: null) and the usage that stream_options requested,
  • the [DONE] line.

The error paths keep their shapes too. The first command asks for a model name that the gateway does not serve, and the second sends a request that is larger than the engine's context window:

Terminal
curl -s -w ' [%{http_code}]\n' $OPENAI_BASE_URL/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H 'Content-Type: application/json' -d '{"model":"gpt-4o","messages":[{"role":"user","content":"x"}]}'
curl -s -w ' [%{http_code}]\n' $OPENAI_BASE_URL/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H 'Content-Type: application/json' -d @long.json | cut -c1-300
Output
{"error":{"message":"The model 'gpt-4o' does not exist","type":"model_not_found"}} [404]
{"error":{"code":400,"message":"request (3030 tokens) exceeds the available context size (2048 tokens), try increasing it","type":"exceed_context_size_error","n_prompt_tokens":3030,"n_ctx":2048}} [400]

The first error is the gateway's own: an SDK configured with a hosted model name that the gateway does not serve gets a 404 and a message that names the model. The second is llama-server's own context error, passed through with its status and fields (long.json holds a request of 3,000 words, which is 3030 tokens against a window of 2048).

No SDK ran in this workshop. This check covers the requests that loop chat and loop agent send. It does not exercise every endpoint of the OpenAI API: embeddings, audio, images, and the Responses API are not routed by the gateway.

Example 18: extension tasks

The gateway leaves several gaps on purpose, so a reader can extend it. Each task below names the class to change and the test that shows the change works.

Task Where Check
Queue requests per model instead of failing with models_busy when every slot is busy BackendManager.makeRoom: wait on a condition with a timeout, then fail A test that holds a lease on alpha, requests beta, releases alpha, and expects beta to load
Load the new engine before evicting the old one when memory allows acquire and makeRoom, with the memory estimate of loop fit A test with a broken model that expects the idle model to stay loaded (Example 13)
Per-model concurrency limit with a Semaphore in Entry Entry, acquire, Lease Two parallel streams to a model with limit 1: the second waits for the first
Per-key rate limit A second filter after ApiKeyFilter, with a token bucket per key Requests above the rate get 429 with a Retry-After header
/api/chat with NDJSON streaming A new method in ChatController that maps the OpenAI chunks to Ollama's lines A test that reads line-delimited JSON
keep_alive per request Read the field in ChatController, store a per-entry timeout A request with keep_alive: 0 unloads the engine after the response
--api-key for loop agent and a status check in the adapter OpenAiModel in the coding-agent lesson Example 15 with a keyed gateway returns the tool call
Restart a dead engine mid-request Backend.isAlive, checked in acquire Kill the fake engine's process between two requests; the second request still succeeds
Prometheus export and TTFT/tokens per second metrics Add micrometer-registry-prometheus; a Timer around the first chunk /actuator/prometheus lists the new meters
Systemd unit A unit file with ExecStart=java -jar loop-gateway.jar --config /etc/loop/gateway.yml systemctl status shows the gateway; journalctl shows the load and unload lines

The tasks are ordered from small to large. The first three change only BackendManager and its tests, which already run without sleeping because the manager takes its time from a Clock.

Interview points

  • A gateway in front of inference engines needs five parts: routing by model name, a backend manager that starts and stops engines, a proxy that does not alter bodies, authentication, and health and metrics.
  • Forwarding the request body as bytes preserves fields that the gateway does not know. Parsing a copy only to read model, messages, and stream keeps the proxy compatible with engine-specific extensions.
  • A CompletableFuture per model entry lets concurrent first requests share one load. Keeping the lock out of the load keeps requests for other models responsive.
  • Eviction chooses an idle model. When every loaded model is busy, the options are to queue the request or to fail with 503, and the choice is a policy.
  • In Spring Framework 7.0.9, the stream passed to StreamingResponseBody ignores flush() unless spring.http.response.flush.enabled is set. Without the property, a streamed answer arrives at the end.
  • The controller's return type must be ResponseEntity<StreamingResponseBody> and not ResponseEntity<?>, because Spring chooses the handler from the declared generic type.
  • With Spring Security, a StreamingResponseBody finishes in an ASYNC dispatch that needs a matching authorization rule.
  • A disconnected client is noticed at the next write. The finally block that closes the lease and the upstream stream is what frees the engine.
  • A metric tag taken from user input (the model name before validation) creates unbounded time series. The counter is therefore incremented only after the model is known.
  • Testing against fake engines that run as processes covers the lifecycle (start, health, crash, kill) that an in-process mock cannot.
  • Jackson 3 in Spring Boot 4 uses the tools.jackson package for databind and core, keeps com.fasterxml.jackson.annotation for annotations, and throws unchecked exceptions.
  • An empty key list that means "no authentication" is acceptable only on a loopback address. A gateway on another interface needs at least one key.

Summary

  • loop gateway is Spring MVC on virtual threads. It has one controller for chat, one for listings, one manager for engines, a filter for keys, and Actuator.
  • Settings are a validated @ConfigurationProperties record. A model has either a url or a command with a {port} placeholder.
  • ProcessBackend picks a free port, starts the command, keeps the last log line, polls /health with backoff, and kills the process tree on close.
  • BackendManager.acquire loads an engine on the first request for a model, shares the load among concurrent requests, evicts the least recently used idle model at the limit, and fails with 503 when none is idle.
  • unloadIdle(Instant) stops models that were idle for longer than idle-timeout. A scheduled method calls it with the current time, and tests call it with chosen times.
  • The chat endpoint forwards the original body, returns upstream errors with their status and body, and reports its own errors in the OpenAI error shape.
  • Streaming needs spring.http.response.flush.enabled=true in spring.properties in Spring Framework 7.0.9, and a ResponseEntity<StreamingResponseBody> return type.
  • A client that disconnects causes a write failure, and the lease and the upstream stream close in a finally block.
  • ApiKeyFilter compares keys in constant time and an empty list disables the check. The filter chain permits /actuator/health and the ASYNC and ERROR dispatches.
  • Actuator shows the loaded engines in the health details, a gauge loop.gateway.models.loaded, and a counter loop.gateway.requests tagged by model and streaming mode.
  • The picocli command loop gateway maps --port, --host, and --config to Spring Boot arguments and binds 127.0.0.1 by default.
  • Ten integration tests run the gateway over HTTP against fake engine processes. They cover listing, keys, load on demand, pass-through, streaming, disconnect, eviction, idle unload, errors, and Actuator.
  • The Project 11 ChatClient and the coding agent's OpenAiModel work through the gateway unchanged. The adapter has no key option, so a keyed gateway returns an empty answer to it.
  • The real run started llama-server with the tiny model on the first request, returned its fields (timings, usage, finish_reason) unchanged, and stopped it after the idle timeout.
  • curl requests with SDK-style fields, headers, and stream_options check the compatibility of models, completions, streaming chunks, and error shapes.