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.
<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>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.javaThe 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.
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");
}
}
}
}# 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: 1The 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:
# 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.
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 commandBinding 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 1The 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.
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).
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.
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- One entry per model, shared by all requests. An
Entryholds aCompletableFuture<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. - Loading does not hold the lock.
acquirechanges the maps insidesynchronized, then releases the lock beforeload. Starting an engine can take many seconds. A request for a loaded model therefore does not wait behind a slow load of another model. - Eviction needs an idle victim. To evict a model means to stop it to free a slot.
makeRoompicks the entry with the oldestlastUsedamong 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 503models_busyerror. This is the policy from Loading, Unloading, and Scheduling Models with the queue left out. Example 18 lists the queue as an extension. - Time comes from a
Clock. The scheduled method callsunloadIdle(clock.instant()), and tests callunloadIdle(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:
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.
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 betaExample 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.
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:
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"{"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.
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 findmodel,messages, andstream, 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'stimingsobject andsystem_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
GatewayExceptionvalues.ErrorAdviceturns them into{"error":{"message":...,"type":...}}, which is the shape that OpenAI client libraries parse. The two classes are:
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; }
}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:
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"}]}'{"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:
# src/main/resources/spring.properties
spring.http.response.flush.enabled=trueThe 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).
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"}]}'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.254662sWithout 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]:
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"}]}'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.
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;
}
}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.isEqualtakes 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/healthis 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
StreamingResponseBodycompletes in a second servlet dispatch of typeASYNC. TheOncePerRequestFilterdoes not run again for it, and the stateless chain has no stored authentication. WithoutdispatcherTypeMatchers(ASYNC, ERROR).permitAll(), the authorization rules can reject that dispatch after the response has started.
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/metricsHTTP/1.1 401
{"error":{"message":"Incorrect API key provided","type":"invalid_api_key"}}
200
401The 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.)
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:
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}'{"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.
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:
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 18770Usage: 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
streamis 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.
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.
@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:
@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):
mvn -B test[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 SUCCESSThe 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.
@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.
@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.
@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).
@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:
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{"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.
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());
}
}
}java -cp "clients/out:.java-libs/*" GatewayChat http://127.0.0.1:18770/v1 sk-local-1 alphawrong 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.
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());
}
}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 alphatool call: read_file id=call_1 args={"path":"pom.xml"} tokens=20+8
text answer: [] tokens=0+0The 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.
# 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.jinjajava -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 .{"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:
java -Dstdout.encoding=UTF-8 -cp "clients/out:.java-libs/*" GatewayChat http://127.0.0.1:18772/v1 sk-local-1 tiny 8wrong 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:
sleep 25
curl -s localhost:18772/api/ps -H 'Authorization: Bearer sk-local-1'
ps -eo args | grep -c "[l]lama-server.*--alias tiny"{"models":[]}
0The 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).
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})}'{"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:
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{"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
choicesarray (shown asdelta: null) and the usage thatstream_optionsrequested, - 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:
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{"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, andstreamkeeps the proxy compatible with engine-specific extensions. - A
CompletableFutureper 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
StreamingResponseBodyignoresflush()unlessspring.http.response.flush.enabledis set. Without the property, a streamed answer arrives at the end. - The controller's return type must be
ResponseEntity<StreamingResponseBody>and notResponseEntity<?>, because Spring chooses the handler from the declared generic type. - With Spring Security, a
StreamingResponseBodyfinishes in anASYNCdispatch that needs a matching authorization rule. - A disconnected client is noticed at the next write. The
finallyblock 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.jacksonpackage for databind and core, keepscom.fasterxml.jackson.annotationfor 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 gatewayis 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
@ConfigurationPropertiesrecord. A model has either aurlor acommandwith a{port}placeholder. ProcessBackendpicks a free port, starts the command, keeps the last log line, polls/healthwith backoff, and kills the process tree on close.BackendManager.acquireloads 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 thanidle-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=trueinspring.propertiesin Spring Framework 7.0.9, and aResponseEntity<StreamingResponseBody>return type. - A client that disconnects causes a write failure, and the lease and the upstream stream close in a
finallyblock. ApiKeyFiltercompares keys in constant time and an empty list disables the check. The filter chain permits/actuator/healthand theASYNCandERRORdispatches.- Actuator shows the loaded engines in the health details, a gauge
loop.gateway.models.loaded, and a counterloop.gateway.requeststagged by model and streaming mode. - The picocli command
loop gatewaymaps--port,--host, and--configto Spring Boot arguments and binds127.0.0.1by 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
ChatClientand the coding agent'sOpenAiModelwork 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-serverwith the tiny model on the first request, returned its fields (timings, usage,finish_reason) unchanged, and stopped it after the idle timeout. curlrequests with SDK-style fields, headers, andstream_optionscheck the compatibility of models, completions, streaming chunks, and error shapes.
