Lesson 37 of 73
The Chat Completions Protocol and Streaming in Java
A program talks to a model server the same way a browser talks to a website: it sends an HTTP request and reads an HTTP response. HTTP (HyperText Transfer Protocol) is the standard way for two programs to exchange messages over a network. The client sends a request made of a URL, some headers (small labeled settings such as Content-Type), and a body. The server answers with a status code (200 means success), headers, and a body.
For chat models, the request body is a JSON document that holds the conversation so far, and the response body is a JSON document that holds the assistant's next message. JSON is a plain-text format for structured data: objects in curly braces, lists in square brackets, and text in quotes. OpenAI defined the exact field names for this exchange, and it is called the chat completions API. Every engine in this course (llama-server, vLLM, and Ollama) copies it. A Java client written once therefore works with all of them.
Java client model server
----------- ------------
POST /v1/chat/completions
Authorization: Bearer <key>
{ "model": ..., "messages": [...] } ---->
generates tokens
<---- 200 OK
{ "choices": [ { "message": {...} } ],
"usage": {...} }This lesson builds the client side in plain Java, using java.net.http.HttpClient (the HTTP client in the JDK) and Jackson (a library that converts between Java objects and JSON). It matters for two reasons. First, a model needs seconds to write a long answer, and a program that waits for the whole answer looks frozen. The protocol therefore has a streaming mode that sends the answer in small pieces as they are produced. Second, when the model asks to run a tool, that request also arrives in pieces and must be put back together. Both topics are covered one step at a time, and then combined into Project 11, a ChatClient library that the loop chat and loop agent commands use in later lessons.
After this lesson, a reader can send chat requests, read a streamed answer token by token, assemble tool calls, stop a generation, and handle the differences between servers.
| Idea or API | What it is used for |
|---|---|
Jackson records with SNAKE_CASE naming |
Turning Java objects into the request JSON and the response JSON into Java objects |
HttpClient.send with BodyHandlers.ofString |
Getting a complete response as one string |
| Server-sent events (SSE) | The text format a server uses to stream an answer |
BodyHandlers.ofLines |
Reading a streamed answer line by line while it arrives |
Delta accumulation by index |
Rebuilding text and tool calls from many small pieces |
| Closing the body stream | Cancelling a generation |
Error envelope, HttpTimeoutException |
Understanding failures and deciding when to retry |
logprobs, NDJSON, Responses API events |
Related formats a client meets in practice |
Every program in this lesson runs against a fake server inside the same JVM (Java Virtual Machine). The fake server is a com.sun.net.httpserver.HttpServer bound to 127.0.0.1 (the loopback address, which only the same machine can reach) on port 0. Port 0 tells the operating system to pick any free port, so the programs never clash with something already running. A fake server keeps the programs repeatable and lets them run without a model.
The fake servers do not invent their replies. They replay JSON and SSE text captured from llama-server at the pinned llama.cpp commit, running the course's tiny random-weight model, as described in llama-server: Endpoints, Slots, and Prompt Cache. Because the weights are random, the model's text is meaningless, but the format is exactly what a real model produces. To keep the printed text plain ASCII, most captures used the request field "grammar": "root ::= [A-Za-z ,.]+". A grammar limits which tokens the model may pick, here to tokens made of letters, spaces, commas, and periods. The result ( Inventweights publishers publishers) is nonsense, but real. Where a listing removes fields from a capture to save space, the text says so.
Example 1: a request as Jackson records
The problem: the server expects exact JSON with snake_case field names such as max_tokens, while Java code uses camelCase names such as maxTokens. A record is a short Java class that only holds data, and Jackson can read and write records. Here the records describe the request, and one Jackson setting maps Java's camelCase component names to the protocol's snake_case field names. The program prints two requests: a plain one and a streaming one. Look at which fields appear in each.
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import java.util.List;
public class RequestJson {
record Message(String role, String content) {}
record StreamOptions(boolean includeUsage) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
record ChatRequest(String model, List<Message> messages, Double temperature, Integer maxTokens,
Boolean stream, StreamOptions streamOptions) {}
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
List<Message> messages = List.of(
new Message("system", "You are a coding assistant."),
new Message("user", "Write a Java loop."));
ChatRequest plain = new ChatRequest("tiny-coder", messages, 0.0, 6, null, null);
System.out.println(json.writeValueAsString(plain));
ChatRequest streaming = new ChatRequest("tiny-coder", messages.subList(1, 2), null, 6,
true, new StreamOptions(true));
System.out.println(json.writerWithDefaultPrettyPrinter().writeValueAsString(streaming));
}
}{"model":"tiny-coder","messages":[{"role":"system","content":"You are a coding assistant."},{"role":"user","content":"Write a Java loop."}],"temperature":0.0,"max_tokens":6}
{
"model" : "tiny-coder",
"messages" : [ {
"role" : "user",
"content" : "Write a Java loop."
} ],
"max_tokens" : 6,
"stream" : true,
"stream_options" : {
"include_usage" : true
}
}The first line is the request body for a non-streaming call. PropertyNamingStrategies.SNAKE_CASE turned maxTokens into max_tokens and streamOptions into stream_options, so the records can use normal Java names. Jackson writes record components in declaration order, which keeps the output stable from run to run.
@JsonInclude(JsonInclude.Include.NON_NULL) on ChatRequest leaves out components that are null. This is why the first request has no stream and no stream_options fields at all, and the second has no temperature. Leaving a field out is different from sending a default value. When temperature is absent, the server applies its own default. For llama-server that is 0.8 (its /props endpoint showed it), and other servers use other values (Example 11 compares them). Sending "stream": null instead of omitting the field can fail on servers with strict validation, so omission is the safe form.
The pretty-printed second request is a streaming request. stream: true asks the server to send the answer as server-sent events (a streaming text format covered in Example 4). stream_options.include_usage: true asks for token counts at the end of the stream. The messages list holds objects with a role (system, user, assistant, or tool) and content, in conversation order. Chat Templates, Roles, and Special Tokens showed how the server turns this list into the prompt text that the model actually reads.
Example 2: parsing a response and ignoring unknown fields
The problem: a server's response often contains fields that your records do not declare, and Jackson's default behavior is to treat that as an error. The program parses a captured llama-server response twice. The first parse uses Jackson's default settings and fails. The second allows unknown fields and succeeds.
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import java.util.List;
public class ParseResponse {
// Captured from llama-server (random weights, ASCII-only grammar)
static final String BODY = """
{"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant",
"content":" Inventweights publishers publishers publishers publishers"}}],"created":1791032728,
"model":"tiny-coder","system_fingerprint":"b1-0c1e570","object":"chat.completion",
"usage":{"completion_tokens":6,"prompt_tokens":23,"total_tokens":29,"prompt_tokens_details":{"cached_tokens":22}},
"id":"chatcmpl-tYRxeTsElLIer6kMouE6EQXEJyBeoV0y",
"timings":{"cache_n":22,"prompt_n":1,"prompt_ms":44.203,"predicted_n":6,"predicted_ms":177.678}}
""";
record Message(String role, String content) {}
record Choice(int index, Message message, String finishReason) {}
record PromptTokensDetails(Integer cachedTokens) {}
record Usage(int promptTokens, int completionTokens, int totalTokens, PromptTokensDetails promptTokensDetails) {}
record ChatResponse(String id, String object, long created, String model, List<Choice> choices, Usage usage) {}
public static void main(String[] args) throws Exception {
ObjectMapper strict = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
try {
strict.readValue(BODY, ChatResponse.class);
} catch (Exception e) {
System.out.println("strict mapper: " + e.getClass().getSimpleName());
String msg = e.getMessage();
System.out.println(" " + msg.substring(0, msg.indexOf(" (class")));
}
ObjectMapper lenient = strict.copy().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
ChatResponse r = lenient.readValue(BODY, ChatResponse.class);
Choice c = r.choices().get(0);
System.out.println("id = " + r.id());
System.out.println("object = " + r.object() + ", model = " + r.model());
System.out.println("role = " + c.message().role());
System.out.println("content = [" + c.message().content() + "]");
System.out.println("finish_reason = " + c.finishReason());
System.out.println("usage = " + r.usage());
}
}strict mapper: UnrecognizedPropertyException
Unrecognized field "system_fingerprint"
id = chatcmpl-tYRxeTsElLIer6kMouE6EQXEJyBeoV0y
object = chat.completion, model = tiny-coder
role = assistant
content = [ Inventweights publishers publishers publishers publishers]
finish_reason = length
usage = Usage[promptTokens=23, completionTokens=6, totalTokens=29, promptTokensDetails=PromptTokensDetails[cachedTokens=22]]The default ObjectMapper fails on the first field it does not know. Here that is system_fingerprint, a standard OpenAI field that the record did not declare. The next failure would be timings, a field that only llama-server sends. Every server adds its own extra fields: vLLM adds stop_reason, Ollama adds timings with fewer members, and llama-server adds timings and __verbose in verbose mode. New versions add more. A client that fails on unknown fields breaks when the server is upgraded, so the program turns FAIL_ON_UNKNOWN_PROPERTIES off. copy() makes a second mapper with the same naming strategy, so the first mapper stays strict and the second becomes lenient.
The second parse fills the records, and the printed lines show what each field means:
finish_reasonislengthbecause the request stopped at 6 tokens, the limit it set, not because the model chose to stop.usagemaps to nested records. It reports 23 prompt tokens, 6 completion tokens, and 29 in total.prompt_tokens_details.cached_tokensis 22 of 23. The same prompt had been sent before, and the server reused the KV cache (the stored attention data for earlier tokens), as the llama-server lesson explained. Only 1 prompt token had to be processed again.cachedTokensis anInteger, not anint, because some servers leaveprompt_tokens_detailsout. A missing nested object would otherwise need a default value.
Example 3: a fake server and a synchronous call
The problem: what does a complete request and response look like on the wire from Java? The program starts a fake server that prints what it receives and replies with a captured response. Then it sends one POST request with HttpClient. A synchronous call means the program waits until the whole response has arrived. Look for the server lines (what the engine sees) and the client lines (what the program got back).
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
public class SyncCall {
// Captured from llama-server (random weights, ASCII-only grammar)
static final String REPLY = """
{"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant","content":" Inventweights publishers publishers publishers publishers"}}],"created":1791032728,"model":"tiny-coder","system_fingerprint":"b1-0c1e570","object":"chat.completion","usage":{"completion_tokens":6,"prompt_tokens":23,"total_tokens":29,"prompt_tokens_details":{"cached_tokens":22}},"id":"chatcmpl-tYRxeTsElLIer6kMouE6EQXEJyBeoV0y"}
""";
public static void main(String[] args) throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/v1/chat/completions", exchange -> {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
System.out.println("server got " + exchange.getRequestMethod() + " " + exchange.getRequestURI()
+ ", auth=" + exchange.getRequestHeaders().getFirst("Authorization"));
System.out.println("server got body " + body);
byte[] bytes = REPLY.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream out = exchange.getResponseBody()) { out.write(bytes); }
});
server.start();
String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1";
try (HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1)
.connectTimeout(Duration.ofSeconds(2)).build()) {
String request = """
{"model":"tiny-coder","messages":[{"role":"user","content":"Write a Java loop."}],"max_tokens":6}""";
HttpRequest httpRequest = HttpRequest.newBuilder(URI.create(baseUrl + "/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer no-key")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(request))
.build();
HttpResponse<String> response = http.send(httpRequest, HttpResponse.BodyHandlers.ofString());
JsonNode root = new ObjectMapper().readTree(response.body());
JsonNode choice = root.at("/choices/0");
System.out.println("client status " + response.statusCode()
+ ", content type " + response.headers().firstValue("Content-Type").orElse("?"));
System.out.println("client content [" + choice.at("/message/content").asText() + "]");
System.out.println("client finish " + choice.get("finish_reason").asText()
+ ", tokens " + root.at("/usage/prompt_tokens").asInt() + "+" + root.at("/usage/completion_tokens").asInt());
} finally {
server.stop(0);
}
}
}server got POST /v1/chat/completions, auth=Bearer no-key
server got body {"model":"tiny-coder","messages":[{"role":"user","content":"Write a Java loop."}],"max_tokens":6}
client status 200, content type application/json; charset=utf-8
client content [ Inventweights publishers publishers publishers publishers]
client finish length, tokens 23+6The server lines show the request as the engine sees it: POST /v1/chat/completions, the Authorization: Bearer no-key header, and the JSON body. Local servers need no key unless they were started with one (--api-key for llama-server), and they accept any value otherwise. The llama-server README passes the placeholder sk-no-key-required to the openai Python client for the same reason. Sending a placeholder keeps the client code identical for local and hosted servers.
The client lines show the status, the content type, the text, the finish reason, and the token counts (23 prompt tokens plus 6 generated tokens). Several details matter for real use:
HttpClientis created once and reused. It keeps the TCP connection (the underlying network link) open between requests, which saves a connection setup on every turn of a chat. Since Java 21,HttpClientimplementsAutoCloseable, so try-with-resources shuts it down.version(HTTP_1_1)is set because local engines speak HTTP/1.1. The JDK client's default version is HTTP/2, and for plain-HTTP servers it first asks the server to upgrade, which local engines do not support.- There are two timeouts.
connectTimeoutlimits how long the TCP connection setup may take. The per-requesttimeoutlimits the wait for the response headers. For a non-streaming request, the headers arrive only after the whole answer is generated, so this timeout must be longer than the longest generation. Example 10 shows the timeout firing. root.at("/choices/0")uses a JSON Pointer, a path syntax for JSON trees. It reaches nested nodes without a null check at each level.
Example 4: server-sent events from a fixed string
The problem: how is a streamed answer laid out as text? Server-sent events (SSE) is a text format in which one response body carries a series of events. Each event is a group of field: value lines ended by a blank line. This example studies the format alone, with no network code, by parsing a fixed string that contains every case a chat client meets. The shape looks like this:
data: {"choices":[...first piece...]} <- event 1 (one data line)
(blank line) <- the blank line ends the event
data: {"choices":[...second piece...]} <- event 2
(blank line)
data: [DONE] <- special end marker
(blank line)The program feeds the string line by line into a small parser. Look at how each case in the input turns into an event, or into nothing.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
public class SseParse {
record Event(String name, String data) {}
// One SSE parser: collects "event:" and "data:" fields until a blank line ends the event.
static final class SseParser {
private String name = "message";
private final StringBuilder data = new StringBuilder();
private boolean hasData;
/** Feeds one line (without its line ending); returns an event when a blank line completes one. */
Event feed(String line) {
if (line.isEmpty()) {
if (!hasData) { name = "message"; return null; }
Event e = new Event(name, data.toString());
name = "message"; data.setLength(0); hasData = false;
return e;
}
if (line.startsWith(":")) return null; // comment, e.g. keep-alive ping
int colon = line.indexOf(':');
String field = colon < 0 ? line : line.substring(0, colon);
String value = colon < 0 ? "" : line.substring(colon + 1);
if (value.startsWith(" ")) value = value.substring(1); // one optional space after the colon
switch (field) {
case "data" -> { if (hasData) data.append('\n'); data.append(value); hasData = true; }
case "event" -> name = value;
default -> { } // id, retry, unknown fields
}
return null;
}
}
public static void main(String[] args) throws IOException {
String wire = """
data: {"choices":[{"index":0,"delta":{"role":"assistant","content":null}}]}
:
data: {"choices":[{"index":0,"delta":{"content":" Invent"}}]}
data:{"choices":[{"index":0,
data: "delta":{"content":"weights"}}]}
event: response.output_text.delta
data: {"type":"response.output_text.delta","delta":"x"}
data: [DONE]
""";
SseParser parser = new SseParser();
List<Event> events = new ArrayList<>();
BufferedReader reader = new BufferedReader(new StringReader(wire));
for (String line; (line = reader.readLine()) != null; ) {
Event e = parser.feed(line);
if (e != null) events.add(e);
}
for (int i = 0; i < events.size(); i++) {
Event e = events.get(i);
String kind = e.data().equals("[DONE]") ? "end of stream" : "json";
System.out.printf("event %d name=%s kind=%s data=%s%n", i, e.name(), kind, e.data().replace("\n", "\\n"));
}
}
}event 0 name=message kind=json data={"choices":[{"index":0,"delta":{"role":"assistant","content":null}}]}
event 1 name=message kind=json data={"choices":[{"index":0,"delta":{"content":" Invent"}}]}
event 2 name=message kind=json data={"choices":[{"index":0,\n"delta":{"content":"weights"}}]}
event 3 name=response.output_text.delta kind=json data={"type":"response.output_text.delta","delta":"x"}
event 4 name=message kind=end of stream data=[DONE]The parser follows the rules of the SSE standard that matter here:
- A line starting with
data:adds to the event's data. One space after the colon is removed. Event 1 useddata:with a space and event 2 started withdata:without one, and both parse the same. - Several
data:lines in one event are joined with a newline. Event 2 was split over two lines and arrives as one JSON text containing\n. That is still valid JSON, because a newline is only whitespace between tokens. - A blank line ends the event. A blank line with no data before it produces nothing.
- A line starting with
:is a comment. llama-server sends:followed by a blank line as a keep-alive ping when it has nothing to send for 30 seconds (--sse-ping-interval). A keep-alive ping tells the connection that the server is still there. The parser skips it, which is why no event appears for it. event:sets the event's name. Without it, the name ismessage. The chat completions stream does not use names, but the Responses API does (Example 14).
[DONE] is not part of SSE. It is a convention of the OpenAI protocol that marks the end of the stream. It is not JSON, so a client must check for it before calling the JSON parser, as the program's kind column shows. llama-server, vLLM, and Ollama all write each event as a single data: line followed by a blank line. The sources show this: format_oai_sse in llama-server's server-common.cpp, yield f"data: {data}\n\n" in vLLM's chat serving code, and fmt.Sprintf("data: %s\n\n", d) in Ollama's middleware/openai.go. The multi-line case therefore does not occur with these three servers. A client that handles it also works with other SSE sources.
Example 5: streaming with BodyHandlers.ofLines
The problem: how does a Java client read tokens while the server is still generating? HttpResponse.BodyHandlers.ofLines() returns a lazy Stream<String> (a Java stream that produces each element only when asked) that yields each line as soon as it arrives. The fake server replays a captured stream, with envelope fields such as id and created removed. After the second content chunk, it waits until the client has printed two deltas before it continues. A delta is the small new piece of the answer in one chunk. Watch the order of the output lines. It shows whether the client really reads while the server is still writing.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
public class StreamLines {
// Captured from llama-server; envelope fields (id, created, model, ...) removed for length
static final List<String> EVENTS = List.of(
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"content\":\" Invent\"}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"content\":\"weights\"}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"content\":\" publishers\"}}]}",
"{\"choices\":[{\"finish_reason\":\"length\",\"index\":0,\"delta\":{}}]}",
"[DONE]");
public static void main(String[] args) throws Exception {
CountDownLatch clientSawTwoDeltas = new CountDownLatch(1);
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/v1/chat/completions", exchange -> {
exchange.getRequestBody().readAllBytes();
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.sendResponseHeaders(200, 0); // 0 = chunked, length unknown
try (OutputStream out = exchange.getResponseBody()) {
for (int i = 0; i < EVENTS.size(); i++) {
out.write(("data: " + EVENTS.get(i) + "\n\n").getBytes(StandardCharsets.UTF_8));
out.flush();
if (i == 2) {
boolean seen = clientSawTwoDeltas.await(5, TimeUnit.SECONDS);
System.out.println(" server: stream still open, client already printed 2 deltas: " + seen);
}
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
server.start();
try (HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()) {
HttpRequest request = HttpRequest.newBuilder(
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/chat/completions"))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString("{\"messages\":[],\"stream\":true}"))
.build();
HttpResponse<Stream<String>> response = http.send(request, HttpResponse.BodyHandlers.ofLines());
System.out.println("status " + response.statusCode() + ", " + response.headers().firstValue("Content-Type").orElse(""));
ObjectMapper json = new ObjectMapper();
int deltas = 0;
try (Stream<String> lines = response.body()) {
for (String line : (Iterable<String>) lines::iterator) {
if (!line.startsWith("data: ")) continue;
String data = line.substring(6);
if (data.equals("[DONE]")) { System.out.println("[DONE]"); break; }
JsonNode delta = json.readTree(data).at("/choices/0/delta");
if (delta.hasNonNull("content")) {
deltas++;
System.out.println("delta " + deltas + ": [" + delta.get("content").asText() + "]");
if (deltas == 2) clientSawTwoDeltas.countDown();
}
}
}
} finally {
server.stop(0);
}
}
}status 200, text/event-stream
delta 1: [ Invent]
delta 2: [weights]
server: stream still open, client already printed 2 deltas: true
delta 3: [ publishers]
[DONE]The output order proves that the client processes the stream incrementally. The server stopped writing after the weights chunk and waited for the client's signal. The client printed delta 1 and delta 2 during that pause. That is only possible because ofLines delivered those lines before the response ended. The server line then reports true, and the rest of the stream follows. If the client had waited for the whole body, the server line would have timed out after 5 seconds and said false.
sendResponseHeaders(200, 0) makes the fake server use chunked transfer encoding, the HTTP/1.1 way to send a body of unknown length. The body goes out in pieces, and each piece announces its own size. http.send returns as soon as the status line and headers arrive, not when the body ends, so the client sees status 200 and text/event-stream before it reads any event. llama-server sends its status line and headers once the first result of the task exists. That is, it sends them after the prompt has been processed and the first token sampled. The role-only delta and the first token then follow at once, and every later chunk is written as soon as its token exists.
Each chunk's delta holds only what is new. The first chunk has role and a null content, so the program skips it with hasNonNull. The finish chunk has an empty delta. The loop ends at [DONE], and closing the Stream in try-with-resources releases the connection.
Example 6: accumulating deltas, finish_reason, and usage
The problem: the chunks are fragments, and the program needs one finished message. A small builder keeps the role, appends each piece of content to a growing string, and records the finish reason and the usage. The program feeds in a captured stream that was requested with stream_options.include_usage. After the run, compare the builder's final content with the non-streaming answer from Example 2.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Accumulate {
// Captured from llama-server with stream_options.include_usage; id/created/model fields removed except on the last chunk
static final String WIRE = """
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"role":"assistant","content":null}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":" Invent"}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"weights"}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":" publishers"}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":" publishers"}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":" publishers"}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":" publishers"}}]}
data: {"choices":[{"finish_reason":"length","index":0,"delta":{}}]}
data: {"choices":[],"id":"chatcmpl-CyVpWRHnMjGe3Gi6jlt8pDtXxEPG7nzw","object":"chat.completion.chunk","usage":{"completion_tokens":6,"prompt_tokens":23,"total_tokens":29,"prompt_tokens_details":{"cached_tokens":22}}}
data: [DONE]
""";
static final class MessageBuilder {
String role;
final StringBuilder content = new StringBuilder();
String finishReason;
JsonNode usage;
int chunks;
void accept(JsonNode chunk) {
chunks++;
if (chunk.hasNonNull("usage")) usage = chunk.get("usage");
for (JsonNode choice : chunk.get("choices")) { // empty in the usage-only chunk
JsonNode delta = choice.get("delta");
if (delta.hasNonNull("role")) role = delta.get("role").asText();
if (delta.hasNonNull("content")) content.append(delta.get("content").asText());
if (choice.hasNonNull("finish_reason")) finishReason = choice.get("finish_reason").asText();
}
}
}
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper();
MessageBuilder message = new MessageBuilder();
for (String line : WIRE.split("\n")) {
if (!line.startsWith("data: ")) continue;
String data = line.substring(6);
if (data.equals("[DONE]")) break;
message.accept(json.readTree(data));
}
System.out.println("chunks parsed = " + message.chunks);
System.out.println("role = " + message.role);
System.out.println("content = [" + message.content + "]");
System.out.println("finish_reason = " + message.finishReason);
System.out.println("usage = " + message.usage);
System.out.println("cached prompt = " + message.usage.at("/prompt_tokens_details/cached_tokens").asInt()
+ " of " + message.usage.get("prompt_tokens").asInt());
}
}chunks parsed = 9
role = assistant
content = [ Inventweights publishers publishers publishers publishers]
finish_reason = length
usage = {"completion_tokens":6,"prompt_tokens":23,"total_tokens":29,"prompt_tokens_details":{"cached_tokens":22}}
cached prompt = 22 of 23Nine chunks were parsed: one role chunk, six content chunks, the finish chunk, and the usage chunk. The content is the concatenation of all delta.content strings. It equals the non-streaming answer of Example 2, because the same request was sent with temperature 0 (the model always picks its most likely token, so the answer is repeatable).
The finish reason arrives in a chunk of its own, after the last text. The usage arrives in a separate, final chunk whose choices array is empty. A builder that assumes choices[0] exists throws on that chunk, which is why accept loops over choices instead of indexing it. Without include_usage, there is no usage chunk at all, and llama-server then puts its timings on the finish chunk instead, as the llama-server lesson showed.
Usage is the only source of exact token counts for a streamed answer. Counting chunks is not reliable, because servers can merge several tokens into one chunk. For example, vLLM's request type has a stream_interval field, described as the number of tokens to batch into each streamed chunk.
Example 7: tool-call deltas accumulated by index
The problem: when the model asks to call a tool (a function such as read_file that the program runs on the model's behalf), the call is also streamed in pieces. A single request can contain several calls, so each piece carries an index that says which call it belongs to. The first piece for an index carries the id, the type, and the function name. Later pieces carry only more of the arguments text. The program replays the tool-call stream captured in the llama-server lesson (envelope fields removed) and joins the pieces. The picture below shows the idea:
chunk 2: index 0, id=5zt..., name=read_file, arguments="{"
chunk 3: index 0, arguments="\n \t..."
chunk 4: index 0, arguments="path"
...
chunk 13: index 0, arguments=" }"
join all arguments -> {"path":"src/Main.java" }Look at the number of pieces, the raw joined text, and the parsed result.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.TreeMap;
public class ToolDeltas {
// Captured from llama-server (Qwen2.5 template, tool_choice required); envelope fields removed
static final String WIRE = """
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"role":"assistant","content":null}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"id":"5ztHnIb8odTda96BAIowHvag0ZjNWH7b","type":"function","function":{"name":"read_file","arguments":"{"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\n \\t\\t\\t \\t\\t\\t \\t\\t\\t \\t\\t\\t\\""}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"path"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":\\""}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"sr"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"c"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"/M"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ai"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".java"}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\""}}]}}]}
data: {"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }"}}]}}]}
data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{}}]}
data: [DONE]
""";
static final class CallBuilder {
String id, type, name;
final StringBuilder arguments = new StringBuilder();
int pieces;
}
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper();
Map<Integer, CallBuilder> calls = new TreeMap<>();
String finish = null;
for (String line : WIRE.split("\n")) {
String data = line.substring("data: ".length());
if (data.equals("[DONE]")) break;
JsonNode choice = json.readTree(data).at("/choices/0");
if (choice.hasNonNull("finish_reason")) finish = choice.get("finish_reason").asText();
for (JsonNode d : choice.at("/delta/tool_calls")) {
CallBuilder call = calls.computeIfAbsent(d.get("index").asInt(), i -> new CallBuilder());
if (d.hasNonNull("id")) call.id = d.get("id").asText();
if (d.hasNonNull("type")) call.type = d.get("type").asText();
JsonNode f = d.path("function");
if (f.hasNonNull("name")) call.name = f.get("name").asText();
if (f.hasNonNull("arguments")) { call.arguments.append(f.get("arguments").asText()); call.pieces++; }
}
}
System.out.println("finish_reason = " + finish);
for (var e : calls.entrySet()) {
CallBuilder c = e.getValue();
System.out.println("call " + e.getKey() + ": id=" + c.id + " type=" + c.type + " name=" + c.name
+ " pieces=" + c.pieces);
System.out.println(" raw arguments: " + c.arguments.toString().replace("\n", "\\n").replace("\t", "\\t"));
JsonNode parsed = json.readTree(c.arguments.toString());
System.out.println(" parsed: " + parsed + " -> path=" + parsed.get("path").asText());
}
}
}finish_reason = tool_calls
call 0: id=5ztHnIb8odTda96BAIowHvag0ZjNWH7b type=function name=read_file pieces=12
raw arguments: {\n \t\t\t \t\t\t \t\t\t \t\t\t"path":"src/Main.java" }
parsed: {"path":"src/Main.java"} -> path=src/Main.javaThe call's arguments arrived in 12 pieces, split at token boundaries: {, a run of whitespace and a quote, path, ":", sr, c, /M, ai, n, .java, ", and }. None of the pieces is valid JSON on its own, and some split a string in the middle (sr + c + /M). The client therefore appends the raw text per index and parses the JSON only after the stream ends with finish_reason: "tool_calls". The whitespace is real: the random model filled the gaps that the tool-call grammar allows, and a JSON parser ignores it.
TreeMap keeps the calls ordered by index for printing. computeIfAbsent creates the builder on the first chunk for an index. The program sets fields only when they are present, because later chunks for the same index carry only index and function.arguments. The id is what the client must send back together with the tool result, so the program stores it from the first chunk.
Example 8: two tool calls and a truncated call
The problem: what must the accumulator check before it runs a tool? This capture allowed parallel tool calls, meaning several calls in one answer, and it stopped at max_tokens: 100. So the model finished one call and started a second, and then the token limit cut the second one off. The program parses each call's arguments and prints whether each is complete.
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.TreeMap;
public class ToolDeltasTwo {
// Captured from llama-server (parallel tool calls allowed, max_tokens 100); envelope fields removed
static final String WIRE = """
{"choices":[{"finish_reason":null,"index":0,"delta":{"role":"assistant","content":null}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"id":"GbpMe8NnqSqLz6tof3Wj7oIK61dgnBde","type":"function","function":{"name":"read_file","arguments":"{"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\n \\t\\t\\t \\t\\t\\t \\t\\t\\t \\t\\t\\t\\""}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"path"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":\\""}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"sr"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"c"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"/M"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ai"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"n"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".java"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\""}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" }"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":1,"id":"8dP5VcT1eTIV1UIbbnp9jFhil954Drwp","type":"function","function":{"name":"read_file","arguments":"{"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\\n \\t\\t\\t \\t\\t\\t \\t\\t\\t \\t\\t\\t\\""}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"path"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"\\":\\""}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"sr"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"c"}}]}}]}
{"choices":[{"finish_reason":null,"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"/M"}}]}}]}
{"choices":[{"finish_reason":"length","index":0,"delta":{}}]}
""";
record Call(String id, String name, StringBuilder arguments) {}
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper();
Map<Integer, Call> calls = new TreeMap<>();
String finish = null;
for (String data : WIRE.split("\n")) {
JsonNode choice = json.readTree(data).at("/choices/0");
if (choice.hasNonNull("finish_reason")) finish = choice.get("finish_reason").asText();
for (JsonNode d : choice.at("/delta/tool_calls")) {
Call call = calls.computeIfAbsent(d.get("index").asInt(),
i -> new Call(d.path("id").asText(), d.at("/function/name").asText(), new StringBuilder()));
call.arguments().append(d.at("/function/arguments").asText());
}
}
System.out.println("finish_reason = " + finish + ", calls = " + calls.size());
for (var e : calls.entrySet()) {
Call c = e.getValue();
try {
JsonNode parsed = json.readTree(c.arguments().toString());
System.out.println("call " + e.getKey() + " " + c.name() + " id=" + c.id().substring(0, 8) + "... complete: " + parsed);
} catch (JsonProcessingException ex) {
String tail = c.arguments().substring(c.arguments().lastIndexOf("\""));
System.out.println("call " + e.getKey() + " " + c.name() + " id=" + c.id().substring(0, 8)
+ "... incomplete, arguments end with " + tail + " -> " + ex.getClass().getSimpleName());
}
}
boolean runTools = "tool_calls".equals(finish);
System.out.println("run the tools: " + runTools);
}
}finish_reason = length, calls = 2
call 0 read_file id=GbpMe8Nn... complete: {"path":"src/Main.java"}
call 1 read_file id=8dP5VcT1... incomplete, arguments end with "src/M -> JsonEOFException
run the tools: falseThe chunks for index 1 started after call 0 was complete, and the second call got its own id. The stream then ended with finish_reason: "length": the token limit cut the second call off after "src/M. Parsing its arguments throws JsonEOFException, which means the input ended inside a string. The program checks the finish reason and refuses to run any tool, including the complete first call. The model's turn did not end normally, so the set of calls is incomplete, and running only part of it could leave the work in an inconsistent state.
A client that runs tools must treat tool_calls as the only finish reason that allows execution. Even then it must parse each call's arguments with error handling, because a model can emit arguments that are valid JSON up to the end of the stream and still miss a required field. A later lesson on the agent loop builds on these checks.
Example 9: cancellation by closing the stream
The problem: how does a client stop a generation that is no longer wanted, for example when a user presses Ctrl+C? Closing the body stream closes the connection, and the server notices the closed connection when it writes the next chunk. The fake server plans 2,000 chunks with 2 ms between them, and the client stops after 3. Look at whether the server really stops early.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
public class CancelStream {
static final int PLANNED = 2000;
public static void main(String[] args) throws Exception {
CompletableFuture<Integer> serverResult = new CompletableFuture<>();
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/v1/chat/completions", exchange -> {
exchange.getRequestBody().readAllBytes();
exchange.getResponseHeaders().set("Content-Type", "text/event-stream");
exchange.sendResponseHeaders(200, 0);
int written = 0;
try (OutputStream out = exchange.getResponseBody()) {
for (; written < PLANNED; written++) {
String chunk = "{\"choices\":[{\"index\":0,\"delta\":{\"content\":\" publishers\"}}]}";
out.write(("data: " + chunk + "\n\n").getBytes(StandardCharsets.UTF_8));
out.flush();
Thread.sleep(2); // a slow model
}
out.write("data: [DONE]\n\n".getBytes(StandardCharsets.UTF_8));
} catch (IOException | InterruptedException e) {
// the client went away: a real server cancels the generation here
} finally {
serverResult.complete(written);
}
});
server.start();
try (HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()) {
HttpRequest request = HttpRequest.newBuilder(
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/v1/chat/completions"))
.POST(HttpRequest.BodyPublishers.ofString("{\"stream\":true}")).build();
HttpResponse<Stream<String>> response = http.send(request, HttpResponse.BodyHandlers.ofLines());
ObjectMapper json = new ObjectMapper();
int received = 0;
try (Stream<String> lines = response.body()) { // close() cancels the body
Iterator<String> it = lines.iterator();
while (it.hasNext() && received < 3) {
String line = it.next();
if (!line.startsWith("data: ")) continue;
received++;
String text = json.readTree(line.substring(6)).at("/choices/0/delta/content").asText();
System.out.println("client delta " + received + ": [" + text + "]");
}
System.out.println("client: stop requested, closing the stream");
}
int written = serverResult.get(10, TimeUnit.SECONDS);
System.out.println("server: stopped before the planned " + PLANNED + " events: " + (written < PLANNED));
} finally {
server.stop(0);
}
}
}client delta 1: [ publishers]
client delta 2: [ publishers]
client delta 3: [ publishers]
client: stop requested, closing the stream
server: stopped before the planned 2000 events: trueThe client read three deltas and left the try-with-resources block, which closed the Stream returned by ofLines. Closing it cancels the subscription to the response body, and the JDK client closes the HTTP/1.1 connection instead of reading the rest of the body. The server's next writes failed with an IOException, the handler stopped, and it reported that it stopped well before 2,000 events. The exact number of events written varies with timing, so the program prints only the comparison.
A real engine reacts the same way. llama-server checks for a closed connection while streaming and cancels the task. Its log for such a request shows stop: cancel task followed by the slot release, and the slot is free for the next request. A non-streaming request behaves differently. The server has nothing to write until the answer is complete, so it has no point at which it would notice the closed connection early, and the client has no partial text to show. Streaming is therefore the mode to use whenever a user may interrupt.
Example 10: errors and timeouts
The problem: how does a client report HTTP errors, and when should it retry? The fake server runs three scenarios. First it returns a captured context-size error. Then it returns 503 Loading model twice before succeeding. Finally it answers too slowly for the client's timeout. The status code (such as 400 or 503) groups errors: 4xx means the request is wrong, and 503 means the server cannot serve right now. Compare how the program reacts to each case.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class ErrorsTimeouts {
static final class ApiException extends IOException {
final int status; final String type;
ApiException(int status, String type, String message) {
super(status + " " + type + ": " + message);
this.status = status; this.type = type;
}
}
static void reply(HttpExchange ex, int status, String body) throws IOException {
ex.getRequestBody().readAllBytes();
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json");
ex.sendResponseHeaders(status, bytes.length);
try (OutputStream out = ex.getResponseBody()) { out.write(bytes); }
}
static String post(HttpClient http, String url, Duration timeout) throws IOException, InterruptedException {
HttpRequest req = HttpRequest.newBuilder(URI.create(url)).timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString("{}")).build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
JsonNode err = new ObjectMapper().readTree(res.body()).path("error");
throw new ApiException(res.statusCode(), err.path("type").asText("unknown"), err.path("message").asText());
}
return res.body();
}
public static void main(String[] args) throws Exception {
AtomicInteger loadingCalls = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // daemon threads
// bodies captured from llama-server
server.createContext("/ctx", ex -> reply(ex, 400, "{\"error\":{\"code\":400,\"message\":\"request (2118 tokens) exceeds the available context size (2048 tokens), try increasing it\",\"type\":\"exceed_context_size_error\",\"n_prompt_tokens\":2118,\"n_ctx\":2048}}"));
server.createContext("/loading", ex -> {
if (loadingCalls.incrementAndGet() <= 2) reply(ex, 503, "{\"error\":{\"message\":\"Loading model\",\"type\":\"unavailable_error\",\"code\":503}}");
else reply(ex, 200, "{\"status\":\"ok\"}");
});
server.createContext("/slow", ex -> {
try { Thread.sleep(2000); } catch (InterruptedException ignored) { }
reply(ex, 200, "{}");
});
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
try (HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()) {
try {
post(http, base + "/ctx", Duration.ofSeconds(5));
} catch (ApiException e) {
System.out.println("400 case: " + e.getMessage());
System.out.println(" retry? " + (e.status == 503) + ", type=" + e.type);
}
long delayMs = 50;
for (int attempt = 1; ; attempt++) {
try {
System.out.println("attempt " + attempt + ": " + post(http, base + "/loading", Duration.ofSeconds(5)));
break;
} catch (ApiException e) {
System.out.println("attempt " + attempt + ": " + e.getMessage() + ", waiting " + delayMs + " ms");
if (e.status != 503 || attempt == 5) throw e;
Thread.sleep(delayMs);
delayMs *= 2;
}
}
try {
post(http, base + "/slow", Duration.ofMillis(300));
} catch (HttpTimeoutException e) {
System.out.println("timeout case: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
} finally {
server.stop(0);
}
}
}400 case: 400 exceed_context_size_error: request (2118 tokens) exceeds the available context size (2048 tokens), try increasing it
retry? false, type=exceed_context_size_error
attempt 1: 503 unavailable_error: Loading model, waiting 50 ms
attempt 2: 503 unavailable_error: Loading model, waiting 100 ms
attempt 3: {"status":"ok"}
timeout case: HttpTimeoutException: request timed outpost converts any status of 400 or more into an ApiException. The exception carries the status, the error type, and the message from the error envelope, a JSON object of the form {"error": {"message", "type", ...}}. HttpClient itself never throws for an HTTP error status. The status is only a number in the response, so checking it is the client's job.
The three cases need three different reactions:
- The 400
exceed_context_size_errorwill fail the same way on every retry, because the prompt is too long for the context window. The client must shorten the conversation instead. - The 503
unavailable_errormeans the model is still loading (or, for/slots?fail_on_no_slot=1, that all slots are busy). Retrying after a delay is correct. The loop doubles the delay (50 ms, then 100 ms) and gives up after 5 attempts. This pattern is called exponential backoff. A production client also adds random jitter, a small random extra wait, so that many clients do not retry at the same moment. - The slow response produced
HttpTimeoutException: request timed outafter 300 ms, because the request'stimeoutcovers the wait for the response headers. For a streaming request the headers come with the first chunk, so the same timeout bounds the time to first token, which is the prompt processing time. It does not limit the length of the whole stream.
The fake server uses an executor of virtual threads so that its handlers run on daemon threads. The default single dispatcher thread, or a cached thread pool, would keep the JVM alive after main returns while the slow handler sleeps.
Example 11: server differences and extra fields
The problem: the servers copy the same protocol, but they do not copy it identically. The table compares the servers this course uses. Each fact is taken from the pinned source: llama-server from llama.cpp tools/server/ at 0c1e57098, vLLM 0.30.0 from vllm/entrypoints/openai/chat_completion/protocol.py and serving.py (shared types in vllm/entrypoints/serve/engine/protocol.py and vllm/entrypoints/generate/base/protocol.py), and Ollama v0.35.1 from openai/openai.go and middleware/openai.go. The hosted column lists only what the OpenAI API reference documents. A sampler is the step that picks the next token from the model's probabilities, and temperature, top_p, top_k, and min_p are its settings (see the sampling lesson linked below).
| Behavior | llama-server | vLLM 0.30.0 | Ollama v0.35.1 (/v1) |
Hosted OpenAI API |
|---|---|---|---|---|
| Sampling defaults when omitted | temperature 0.8, top_p 0.95, top_k 40, min_p 0.05 | model's generation config, else temperature 1.0, top_p 1.0, top_k 0, min_p 0.0 | temperature 1.0, top_p 1.0 | temperature 1, top_p 1 |
top_k, min_p |
accepted | accepted (also repetition_penalty) |
not in the request type, ignored | not in the API reference |
| Unknown request fields | ignored | ignored, logged at debug level | ignored (Go JSON decoding) | not covered here |
| Reasoning text field | reasoning_content |
reasoning |
reasoning |
not covered here |
tool_choice |
auto, none, required |
auto, none, required, or a named function |
not in the request type | auto, none, required, or a named function |
| Streamed tool calls | arguments in pieces, by index |
arguments in pieces, by index |
whole call in one chunk | arguments in pieces, by index |
| Usage when streaming | include_usage adds a chunk with empty choices |
same, plus continuous_usage_stats |
same, plus timings |
include_usage adds a chunk with empty choices |
| Error envelope | {"error": {"code": int, "message", "type"}} |
{"error": {"message", "type", "param", "code": int}} |
{"error": {"message", "type", "param", "code": null}} |
{"error": {"message", "type", "param", "code"}} |
| Error after the stream started | data: {"error": ...}, then the stream ends without [DONE] |
data: {"error": ...}, then data: [DONE] |
not covered here | not covered here |
| Extra response fields | timings, system_fingerprint = build |
stop_reason, optional token ids |
timings (no cache_n), system_fingerprint = fp_ollama |
system_fingerprint, service_tier |
The hosted column comes from the API reference, not from source code, and hosted APIs change without version pins. The local columns describe exactly the pinned versions. The program below builds the same request for each local server, and then reads the reasoning text in both spellings (reasoning_content and reasoning). Reasoning text is the model's "thinking" text, returned separately from the final answer by models that produce it.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
public class ServerDialects {
enum Server { LLAMA_SERVER, VLLM, OLLAMA_OPENAI }
static final ObjectMapper JSON = new ObjectMapper();
/** Standard fields first; non-standard sampling fields only where the server's request type has them. */
static ObjectNode request(Server server, double temperature, int topK, double minP) {
ObjectNode r = JSON.createObjectNode();
r.put("model", "coder");
r.putArray("messages").addObject().put("role", "user").put("content", "Write a Java loop.");
r.put("temperature", temperature);
r.put("max_tokens", 256);
switch (server) {
case LLAMA_SERVER, VLLM -> { r.put("top_k", topK); r.put("min_p", minP); }
case OLLAMA_OPENAI -> { } // ChatCompletionRequest in openai/openai.go has no top_k or min_p
}
return r;
}
/** llama-server returns reasoning_content; vLLM and Ollama return reasoning. */
static String reasoning(JsonNode message) {
if (message.hasNonNull("reasoning_content")) return message.get("reasoning_content").asText();
if (message.hasNonNull("reasoning")) return message.get("reasoning").asText();
return "";
}
public static void main(String[] args) throws Exception {
for (Server s : Server.values()) {
System.out.printf("%-13s %s%n", s, JSON.writeValueAsString(request(s, 0.2, 20, 0.05)));
}
String[] messages = {
"{\"role\":\"assistant\",\"content\":\"7 is prime.\",\"reasoning_content\":\"check 2, 3\"}",
"{\"role\":\"assistant\",\"content\":\"7 is prime.\",\"reasoning\":\"check 2, 3\"}",
"{\"role\":\"assistant\",\"content\":\"7 is prime.\"}"
};
for (String m : messages) {
JsonNode message = JSON.readTree(m);
System.out.println("reasoning=[" + reasoning(message) + "] content=[" + message.get("content").asText() + "]");
}
}
}LLAMA_SERVER {"model":"coder","messages":[{"role":"user","content":"Write a Java loop."}],"temperature":0.2,"max_tokens":256,"top_k":20,"min_p":0.05}
VLLM {"model":"coder","messages":[{"role":"user","content":"Write a Java loop."}],"temperature":0.2,"max_tokens":256,"top_k":20,"min_p":0.05}
OLLAMA_OPENAI {"model":"coder","messages":[{"role":"user","content":"Write a Java loop."}],"temperature":0.2,"max_tokens":256}
reasoning=[check 2, 3] content=[7 is prime.]
reasoning=[check 2, 3] content=[7 is prime.]
reasoning=[] content=[7 is prime.]The three requests differ only in the non-standard fields. top_k and min_p (the samplers from Sampling: Greedy, Temperature, Top-k, Top-p, and Min-p) are sent to llama-server and vLLM, whose request types declare them. Ollama's OpenAI-compatible ChatCompletionRequest declares neither, and Go's JSON decoder drops undeclared fields without an error, so sending them would silently have no effect. Ollama's native /api/chat accepts them inside an options object instead (Example 13).
The defaults row of the table explains why the same request gives different results on different servers even at the same temperature. An omitted top_p is 0.95 on llama-server and 1.0 on vLLM and Ollama. A client that needs repeatable behavior across servers sends every sampling parameter explicitly.
The reasoning helper reads reasoning_content first and falls back to reasoning, so the third message, which has neither, gives an empty string. vLLM 0.30.0 also accepts reasoning_content in request messages and renames it to reasoning (a validator in its chat protocol file says so), but it only returns reasoning.
Example 12: logprobs in a response
The problem: sometimes a program wants to know how confident the model was in each token. With logprobs: true, the server adds that information to the response. A log probability (logprob) is the natural logarithm of the probability the model gave a token. For example, a token with probability 1 has logprob 0 (certainty), a token with probability 0.5 has logprob about -0.69, and very unlikely tokens have large negative values. The capture asked for top_logprobs: 2, which lists the two most likely alternatives at each position. The program prints each chosen token with its alternatives, then the sum and mean of the logprobs, and compares the mean with a uniform guess over the whole vocabulary (every token equally likely).
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Locale;
public class Logprobs {
// Captured from llama-server: logprobs=true, top_logprobs=2, ASCII-only grammar, random weights
static final String BODY = """
{"choices":[{"finish_reason":"length","index":0,"message":{"role":"assistant","content":" Inventweights publishers"},
"logprobs":{"content":[
{"id":92158,"token":" Invent","bytes":[32,73,110,118,101,110,116],"logprob":-10.670243263244629,
"top_logprobs":[{"id":119530,"token":"瓤","bytes":[231,147,164],"logprob":-10.596213340759277},
{"id":97494,"token":"(\\"(%","bytes":[40,34,40,37],"logprob":-10.60909366607666}]},
{"id":13327,"token":"weights","bytes":[119,101,105,103,104,116,115],"logprob":-10.7026948928833,
"top_logprobs":[{"id":123014,"token":"崶","bytes":[229,180,182],"logprob":-10.638603210449219},
{"id":97494,"token":"(\\"(%","bytes":[40,34,40,37],"logprob":-10.65953540802002}]},
{"id":35617,"token":" publishers","bytes":[32,112,117,98,108,105,115,104,101,114,115],"logprob":-10.583504676818848,
"top_logprobs":[{"id":35617,"token":" publishers","bytes":[32,112,117,98,108,105,115,104,101,114,115],"logprob":-10.583504676818848},
{"id":97494,"token":"(\\"(%","bytes":[40,34,40,37],"logprob":-10.606264114379883}]}]}}]}
""";
public static void main(String[] args) throws Exception {
JsonNode content = new ObjectMapper().readTree(BODY).at("/choices/0/logprobs/content");
double sum = 0;
for (JsonNode t : content) {
double lp = t.get("logprob").asDouble();
sum += lp;
System.out.printf(Locale.ROOT, "chosen %-13s id=%-6d logprob=%.4f p=%.2e bytes=%d%n",
"[" + t.get("token").asText() + "]", t.get("id").asInt(), lp, Math.exp(lp), t.get("bytes").size());
for (JsonNode alt : t.get("top_logprobs")) {
System.out.printf(Locale.ROOT, " top %-13s id=%-6d logprob=%.4f%n",
"[" + alt.get("token").asText() + "]", alt.get("id").asInt(), alt.get("logprob").asDouble());
}
}
System.out.printf(Locale.ROOT, "sequence logprob = %.4f, mean = %.4f%n", sum, sum / content.size());
System.out.printf(Locale.ROOT, "uniform over 151936 tokens = %.4f%n", Math.log(1.0 / 151936));
}
}chosen [ Invent] id=92158 logprob=-10.6702 p=2.32e-05 bytes=7
top [瓤] id=119530 logprob=-10.5962
top [("(%] id=97494 logprob=-10.6091
chosen [weights] id=13327 logprob=-10.7027 p=2.25e-05 bytes=7
top [崶] id=123014 logprob=-10.6386
top [("(%] id=97494 logprob=-10.6595
chosen [ publishers] id=35617 logprob=-10.5835 p=2.53e-05 bytes=11
top [ publishers] id=35617 logprob=-10.5835
top [("(%] id=97494 logprob=-10.6063
sequence logprob = -31.9564, mean = -10.6521
uniform over 151936 tokens = -11.9312Each entry in logprobs.content describes one generated token: its id, its text, its UTF-8 bytes, its logprob, and the top_logprobs list. The math for one token is p = e^logprob. The chosen Invent has logprob -10.67, so its probability is e^-10.67, which is 2.32e-05, about 1 in 43,000. The mean over the three tokens, -10.65, is close to the value for a uniform choice among 151,936 tokens, which is ln(1/151936) = -11.93. A model with random weights spreads its probability almost evenly over the vocabulary, so this result is expected. It is also a quick test for broken weights in a real model: a model that works should give much higher logprobs (closer to 0) for the tokens it picks.
The alternatives show two things about llama-server at this commit:
- The top alternatives at the first two positions (
瓤and崶) were not chosen, even though the request used temperature 0. The ASCII-only grammar removed them before sampling, so greedy selection picked the most likely token that the grammar allowed. - The reported logprobs come from a softmax over all logits before any sampler or the grammar ran, so the alternatives the grammar banned still appear with their raw values. (The logits are the model's raw scores for each token, and the softmax turns them into probabilities.) The source confirms this: unless the request sets
post_sampling_probs: true,populate_token_probsinserver-context.cppcomputes the probabilities withget_token_probabilities, a plain softmax of the model's logits.
A client that uses logprobs as a confidence score therefore has to account for which samplers and constraints were active.
bytes exists because a token may be only part of a multi-byte character. The text of such a token cannot be shown on its own, but its bytes can be joined with the next token's bytes to rebuild the character. The sequence logprob is the sum over tokens (-31.96). It is the log of the probability of the whole answer, because multiplying probabilities means adding their logs. Dividing by the token count (-10.65) gives a score that does not depend on the answer's length.
Example 13: Ollama's native /api/chat NDJSON stream
The problem: Ollama serves two APIs, and the second one looks different. It serves /v1/chat/completions (SSE, the shape seen so far) and its own native /api/chat, which streams NDJSON. NDJSON (newline-delimited JSON) means one complete JSON object per line, with no data: prefix and no [DONE]. The input below follows the field names of ChatResponse, Message, ToolCall, and Metrics in Ollama v0.35.1's api/types.go. The values are illustrative, not captured, because no Ollama server runs in the course's build environment. The program reads each line, collects the text, prints the tool call, and turns the final metrics into speeds.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Locale;
public class OllamaNdjson {
// Shape of POST /api/chat with "stream": true (field names from Ollama v0.35.1 api/types.go; values illustrative)
static final String NDJSON = """
{"model":"qwen2.5-coder:7b","created_at":"2026-10-03T12:00:00.100Z","message":{"role":"assistant","content":"Reading"},"done":false}
{"model":"qwen2.5-coder:7b","created_at":"2026-10-03T12:00:00.120Z","message":{"role":"assistant","content":" the file."},"done":false}
{"model":"qwen2.5-coder:7b","created_at":"2026-10-03T12:00:00.180Z","message":{"role":"assistant","content":"","tool_calls":[{"id":"call_k2v9","function":{"index":0,"name":"read_file","arguments":{"path":"pom.xml"}}}]},"done":false}
{"model":"qwen2.5-coder:7b","created_at":"2026-10-03T12:00:00.200Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":2150000000,"load_duration":1500000000,"prompt_eval_count":120,"prompt_eval_duration":400000000,"eval_count":25,"eval_duration":250000000}
""";
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper();
StringBuilder content = new StringBuilder();
for (String line : NDJSON.split("\n")) { // one JSON object per line, no "data: " prefix
JsonNode r = json.readTree(line);
if (r.has("error")) throw new IllegalStateException(r.get("error").asText()); // {"error": "text"}
JsonNode msg = r.get("message");
content.append(msg.path("content").asText());
for (JsonNode call : msg.path("tool_calls")) {
JsonNode f = call.get("function");
System.out.println("tool call " + f.get("name").asText() + " arguments object " + f.get("arguments")
+ " (isObject=" + f.get("arguments").isObject() + ")");
}
if (r.get("done").asBoolean()) {
double evalSeconds = r.get("eval_duration").asLong() / 1e9; // durations are nanoseconds
double promptSeconds = r.get("prompt_eval_duration").asLong() / 1e9;
System.out.println("content = [" + content + "]");
System.out.println("done_reason = " + r.get("done_reason").asText());
System.out.printf(Locale.ROOT, "prompt: %d tokens in %.3f s = %.1f tok/s%n",
r.get("prompt_eval_count").asInt(), promptSeconds, r.get("prompt_eval_count").asInt() / promptSeconds);
System.out.printf(Locale.ROOT, "output: %d tokens in %.3f s = %.1f tok/s%n",
r.get("eval_count").asInt(), evalSeconds, r.get("eval_count").asInt() / evalSeconds);
System.out.printf(Locale.ROOT, "load: %.3f s of %.3f s total%n",
r.get("load_duration").asLong() / 1e9, r.get("total_duration").asLong() / 1e9);
}
}
}
}tool call read_file arguments object {"path":"pom.xml"} (isObject=true)
content = [Reading the file.]
done_reason = stop
prompt: 120 tokens in 0.400 s = 300.0 tok/s
output: 25 tokens in 0.250 s = 100.0 tok/s
load: 1.500 s of 2.150 s totalEvery line carries model, created_at (an RFC 3339 timestamp, not Unix seconds), a message with only the new text, and done. The last line has done: true, done_reason, and the metrics. The differences from the OpenAI shape that a client must handle:
- Tool calls arrive whole, and
argumentsis a JSON object, not a JSON-encoded string (isObject=true). Ollama'sToolCallFunctiontype also putsindexinsidefunction. - Durations are integers in nanoseconds, because Go encodes
time.Durationthat way. The math: 25 tokens in 250,000,000 ns is 25 / 0.25 s, which is 100 tokens per second.load_durationseparates the time spent loading the model (1.5 s here) from the time spent on inference. - An error is
{"error": "text"}with a plain string, not an object.streamResponseinserver/routes.gowrites it that way, either as the whole response or as a line in the middle of the stream. - The stream ends when a line has
done: true. There is no terminator line. The HTTP content type isapplication/x-ndjson.
The request side of /api/chat also differs. Sampling parameters go into an options object (where top_k and min_p are accepted), and fields such as think and keep_alive control reasoning and how long the model stays loaded. On the native API, reasoning text arrives in message.thinking (the Thinking field of Message), while the /v1 endpoint returns the same text as reasoning. A later lesson on Ollama's architecture covers that API.
Example 14: the Responses API event stream
The problem: OpenAI also has a newer API, the Responses API, and a client may meet it. Its stream looks different, because it sends many kinds of named events instead of one kind of chunk. llama-server implements POST /v1/responses by converting the request to a chat completion and streaming named SSE events. The program parses the complete stream captured from llama-server for the input Say hi. (2 output tokens). It prints each event name, then checks that the finished text equals the joined deltas.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
public class ResponsesEvents {
// Captured from llama-server POST /v1/responses with "stream": true (random weights)
static final String WIRE = """
event: response.created
data: {"type":"response.created","response":{"id":"resp_1ozZJzNQRW59aeykjm40KXDpCt54Bd2n","object":"response","status":"in_progress"}}
event: response.in_progress
data: {"type":"response.in_progress","response":{"id":"resp_1ozZJzNQRW59aeykjm40KXDpCt54Bd2n","object":"response","status":"in_progress"}}
event: response.output_item.added
data: {"type":"response.output_item.added","item":{"content":[],"id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","role":"assistant","status":"in_progress","type":"message"}}
event: response.content_part.added
data: {"type":"response.content_part.added","item_id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","part":{"type":"output_text","text":""}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","item_id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","delta":"🇷"}
event: response.output_text.delta
data: {"type":"response.output_text.delta","item_id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","delta":"🇷"}
event: response.output_text.done
data: {"type":"response.output_text.done","item_id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","text":"🇷🇷"}
event: response.content_part.done
data: {"type":"response.content_part.done","item_id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","part":{"type":"output_text","annotations":[],"logprobs":[],"text":"🇷🇷"}}
event: response.output_item.done
data: {"type":"response.output_item.done","item":{"type":"message","status":"completed","id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"🇷🇷"}],"role":"assistant"}}
event: response.completed
data: {"type":"response.completed","response":{"id":"resp_1ozZJzNQRW59aeykjm40KXDpCt54Bd2n","object":"response","created_at":1791032700,"status":"completed","model":"tiny-coder","output":[{"type":"message","status":"completed","id":"msg_1xwJuyYl5xm2xXEoVDBph00zkN5gw7jM","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"🇷🇷"}],"role":"assistant"}],"usage":{"input_tokens":21,"output_tokens":2,"total_tokens":23,"input_tokens_details":{"cached_tokens":20}}},"timings":{"cache_n":20,"prompt_n":1,"prompt_ms":7.737,"prompt_per_token_ms":7.737,"prompt_per_second":129.24906294429366,"predicted_n":2,"predicted_ms":7.296,"predicted_per_token_ms":7.296,"predicted_per_second":137.06140350877192}}
""";
public static void main(String[] args) throws Exception {
ObjectMapper json = new ObjectMapper();
StringBuilder text = new StringBuilder();
String eventName = null;
for (String line : WIRE.split("\n")) {
if (line.startsWith("event: ")) { eventName = line.substring(7); continue; }
if (!line.startsWith("data: ")) continue;
JsonNode data = json.readTree(line.substring(6));
System.out.println(eventName + (eventName.equals(data.get("type").asText()) ? "" : " (type differs)"));
switch (eventName) {
case "response.output_text.delta" -> text.append(data.get("delta").asText());
case "response.output_text.done" -> System.out.println(" done text equals deltas: " + data.get("text").asText().contentEquals(text));
case "response.completed" -> {
JsonNode r = data.get("response");
System.out.println(" status=" + r.get("status").asText() + " output[0].type=" + r.at("/output/0/type").asText()
+ " text=" + r.at("/output/0/content/0/text").asText());
System.out.println(" usage input=" + r.at("/usage/input_tokens").asInt() + " output=" + r.at("/usage/output_tokens").asInt()
+ " cached=" + r.at("/usage/input_tokens_details/cached_tokens").asInt());
}
default -> { }
}
}
}
}response.created
response.in_progress
response.output_item.added
response.content_part.added
response.output_text.delta
response.output_text.delta
response.output_text.done
done text equals deltas: true
response.content_part.done
response.output_item.done
response.completed
status=completed output[0].type=message text=🇷🇷
usage input=21 output=2 cached=20The stream has ten events, each with an event: line and a data: line whose type field repeats the event name. The program checks this and prints no difference. The order describes a tree being built:
- A response is created and goes
in_progress. - An output item of type
messageis added. - A content part of type
output_textis added. - Text deltas arrive (two here).
- The text, the part, the item, and the response are each marked done.
The final response.completed event carries the whole response with output[0].content[0].text. It also carries a usage object whose names differ from chat completions: input_tokens, output_tokens, and input_tokens_details.cached_tokens (21, 2, and 20 here).
There is no [DONE] line. The stream ends after response.completed (llama-server's source returns an empty string instead of data: [DONE] for this response type). A client of the Responses API therefore keys on event names, not on a terminator. The output_text.done event repeats the full text, which lets a client verify its accumulated deltas, and the program confirms they match. This course uses the chat completions API for loop, because all three local engines support it with the same shape.
Example 15: Project 11, the ChatClient library
The problem: the earlier examples are separate pieces, and the later lessons need one reusable library. ChatClient combines the pieces and has two public methods: chat returns a complete answer, and stream returns a streamed answer, calling a listener for each piece and checking a cancel flag. The records Message, ToolCall, FunctionCall, Tool (with its FunctionDef), Request, Usage, and Result, plus the ApiException class, are the whole data model. The code between the two marker comments is the library, and the rest is a demo.
The demo at the bottom runs five scenarios against a fake server that replays the captures from earlier examples. The tool-call stream is shortened to three argument pieces without the whitespace. As you read the output, match each numbered line to one scenario in the main method.
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.Executors;
import java.util.stream.Stream;
public class ChatClientDemo {
// ---------------- Project 11: the ChatClient library ----------------
@JsonInclude(JsonInclude.Include.NON_NULL)
record Message(String role, String content, List<ToolCall> toolCalls, String toolCallId) {
static Message system(String text) { return new Message("system", text, null, null); }
static Message user(String text) { return new Message("user", text, null, null); }
static Message assistant(String text, List<ToolCall> calls) { return new Message("assistant", text, calls, null); }
static Message tool(String callId, String result) { return new Message("tool", result, null, callId); }
}
record ToolCall(String id, String type, FunctionCall function) {}
record FunctionCall(String name, String arguments) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
record FunctionDef(String name, String description, JsonNode parameters) {}
record Tool(String type, FunctionDef function) {
static Tool function(String name, String description, JsonNode parameters) {
return new Tool("function", new FunctionDef(name, description, parameters));
}
}
@JsonInclude(JsonInclude.Include.NON_NULL)
record Request(String model, List<Message> messages, Double temperature, Integer maxTokens, List<Tool> tools) {}
record Usage(int promptTokens, int completionTokens, int totalTokens) {}
record Result(String content, String reasoning, List<ToolCall> toolCalls, String finishReason, Usage usage) {}
interface Listener {
void onContent(String text);
default void onReasoning(String text) { }
}
static final class Cancel {
private volatile boolean cancelled;
void cancel() { cancelled = true; }
boolean isCancelled() { return cancelled; }
}
static final class ApiException extends IOException {
final int status;
final String type;
ApiException(int status, String type, String message) {
super(status + " " + type + ": " + message);
this.status = status;
this.type = type;
}
}
static final class ChatClient implements AutoCloseable {
private final HttpClient http = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1).connectTimeout(Duration.ofSeconds(5)).build();
private final ObjectMapper json = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final URI endpoint;
private final String apiKey;
/** baseUrl ends in /v1, for example http://127.0.0.1:8080/v1; apiKey may be null. */
ChatClient(String baseUrl, String apiKey) {
this.endpoint = URI.create(baseUrl + "/chat/completions");
this.apiKey = apiKey;
}
/** One request, one complete response. */
Result chat(Request request) throws IOException, InterruptedException {
ObjectNode body = json.valueToTree(request);
body.put("stream", false);
HttpResponse<String> res = http.send(build(body, Duration.ofMinutes(10)), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) throw error(res.statusCode(), json.readTree(res.body()));
JsonNode root = json.readTree(res.body());
JsonNode choice = root.at("/choices/0");
JsonNode message = choice.get("message");
List<ToolCall> calls = new ArrayList<>();
for (JsonNode c : message.path("tool_calls")) calls.add(json.treeToValue(c, ToolCall.class));
return new Result(message.path("content").asText(""), reasoningOf(message), calls,
choice.path("finish_reason").asText(null), usageOf(root));
}
/** Streams one request; deltas go to the listener; returns the accumulated result. */
Result stream(Request request, Listener listener, Cancel cancel) throws IOException, InterruptedException {
ObjectNode body = json.valueToTree(request);
body.put("stream", true);
body.putObject("stream_options").put("include_usage", true);
HttpResponse<Stream<String>> res = http.send(build(body, Duration.ofMinutes(10)), HttpResponse.BodyHandlers.ofLines());
if (res.statusCode() >= 400) {
String text = String.join("\n", res.body().toList());
throw error(res.statusCode(), json.readTree(text));
}
StringBuilder content = new StringBuilder(), reasoning = new StringBuilder();
Map<Integer, String[]> calls = new TreeMap<>(); // index -> {id, name, arguments}
String finish = null;
Usage usage = null;
try (Stream<String> lines = res.body()) { // closing cancels the HTTP exchange
Iterator<String> it = lines.iterator();
while (it.hasNext()) {
if (cancel.isCancelled()) { finish = "cancelled"; break; }
String line = it.next();
if (!line.startsWith("data:")) continue; // blank lines, ":" pings, event names
String data = line.substring(5).trim();
if (data.equals("[DONE]")) break;
JsonNode chunk = json.readTree(data);
if (chunk.has("error")) throw error(res.statusCode(), chunk);
if (chunk.hasNonNull("usage")) usage = usageOf(chunk);
for (JsonNode choice : chunk.path("choices")) {
JsonNode delta = choice.path("delta");
String text = delta.path("content").asText("");
if (!text.isEmpty()) { content.append(text); listener.onContent(text); }
String thought = reasoningOf(delta);
if (!thought.isEmpty()) { reasoning.append(thought); listener.onReasoning(thought); }
for (JsonNode d : delta.path("tool_calls")) {
String[] call = calls.computeIfAbsent(d.path("index").asInt(), i -> new String[] {null, null, ""});
if (d.hasNonNull("id")) call[0] = d.get("id").asText();
if (d.at("/function/name").isTextual()) call[1] = d.at("/function/name").asText();
call[2] += d.at("/function/arguments").asText("");
}
if (choice.hasNonNull("finish_reason")) finish = choice.get("finish_reason").asText();
}
}
}
List<ToolCall> toolCalls = new ArrayList<>();
for (String[] c : calls.values()) toolCalls.add(new ToolCall(c[0], "function", new FunctionCall(c[1], c[2])));
return new Result(content.toString(), reasoning.toString(), toolCalls, finish, usage);
}
private HttpRequest build(JsonNode body, Duration timeout) throws IOException {
HttpRequest.Builder b = HttpRequest.newBuilder(endpoint).timeout(timeout)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(body)));
if (apiKey != null) b.header("Authorization", "Bearer " + apiKey);
return b.build();
}
private static String reasoningOf(JsonNode node) { // llama-server: reasoning_content; vLLM, Ollama: reasoning
if (node.hasNonNull("reasoning_content")) return node.get("reasoning_content").asText();
return node.path("reasoning").asText("");
}
private static Usage usageOf(JsonNode root) {
JsonNode u = root.path("usage");
return u.isMissingNode() || u.isNull() ? null : new Usage(u.path("prompt_tokens").asInt(),
u.path("completion_tokens").asInt(), u.path("total_tokens").asInt());
}
private static ApiException error(int status, JsonNode root) {
JsonNode e = root.path("error");
if (e.isTextual()) return new ApiException(status, "error", e.asText()); // Ollama native shape
return new ApiException(status, e.path("type").asText("unknown"), e.path("message").asText(""));
}
@Override public void close() { http.close(); }
}
// ---------------- end of the library ----------------
// Fake llama-server replaying captured responses (envelope fields removed from the SSE chunks)
static final String SYNC = "{\"choices\":[{\"finish_reason\":\"length\",\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\" Inventweights publishers publishers publishers publishers\"}}],\"object\":\"chat.completion\",\"usage\":{\"completion_tokens\":6,\"prompt_tokens\":23,\"total_tokens\":29}}";
static final List<String> TEXT_SSE = List.of(
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"content\":\" Invent\"}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"content\":\"weights\"}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"content\":\" publishers\"}}]}",
"{\"choices\":[{\"finish_reason\":\"length\",\"index\":0,\"delta\":{}}]}",
"{\"choices\":[],\"usage\":{\"completion_tokens\":3,\"prompt_tokens\":23,\"total_tokens\":26}}",
"[DONE]");
static final List<String> TOOL_SSE = List.of(
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"5ztHnIb8odTda96BAIowHvag0ZjNWH7b\",\"type\":\"function\",\"function\":{\"name\":\"read_file\",\"arguments\":\"{\"}}]}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"path\\\":\\\"\"}}]}}]}",
"{\"choices\":[{\"finish_reason\":null,\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"src/Main.java\\\" }\"}}]}}]}",
"{\"choices\":[{\"finish_reason\":\"tool_calls\",\"index\":0,\"delta\":{}}]}",
"[DONE]");
static final String CTX_ERROR = "{\"error\":{\"code\":400,\"message\":\"request (2118 tokens) exceeds the available context size (2048 tokens), try increasing it\",\"type\":\"exceed_context_size_error\",\"n_prompt_tokens\":2118,\"n_ctx\":2048}}";
static HttpServer fakeServer() throws IOException {
ObjectMapper json = new ObjectMapper();
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.createContext("/v1/chat/completions", ex -> {
JsonNode req = json.readTree(ex.getRequestBody().readAllBytes());
String last = req.at("/messages/" + (req.get("messages").size() - 1) + "/content").asText();
boolean stream = req.path("stream").asBoolean();
try (OutputStream out = ex.getResponseBody()) {
if (last.equals("too long")) {
byte[] b = CTX_ERROR.getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(400, b.length);
out.write(b);
} else if (!stream) {
byte[] b = SYNC.getBytes(StandardCharsets.UTF_8);
ex.sendResponseHeaders(200, b.length);
out.write(b);
} else {
ex.getResponseHeaders().set("Content-Type", "text/event-stream");
ex.sendResponseHeaders(200, 0);
List<String> events = req.has("tools") ? TOOL_SSE : TEXT_SSE;
int repeat = last.equals("long") ? 1000 : 1;
for (int r = 0; r < repeat; r++) {
for (String e : events) {
if (repeat > 1 && (e.equals("[DONE]") || e.contains("finish_reason\":\"") || e.contains("usage"))) continue;
out.write(("data: " + e + "\n\n").getBytes(StandardCharsets.UTF_8));
out.flush();
if (repeat > 1) Thread.sleep(2);
}
}
}
} catch (IOException | InterruptedException ignored) {
// client closed the connection
}
});
server.start();
return server;
}
public static void main(String[] args) throws Exception {
HttpServer server = fakeServer();
String base = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1";
try (ChatClient client = new ChatClient(base, "no-key")) {
List<Message> history = new ArrayList<>(List.of(Message.system("You are a coding assistant."), Message.user("Write a Java loop.")));
Result sync = client.chat(new Request("tiny-coder", history, 0.0, 6, null));
System.out.println("1 chat: [" + sync.content() + "] finish=" + sync.finishReason() + " " + sync.usage());
StringBuilder shown = new StringBuilder();
Result streamed = client.stream(new Request("tiny-coder", history, 0.0, 3, null), t -> shown.append('|').append(t), new Cancel());
System.out.println("2 stream: listener saw " + shown + "| finish=" + streamed.finishReason() + " " + streamed.usage());
JsonNode schema = new ObjectMapper().readTree("{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}");
List<Tool> tools = List.of(Tool.function("read_file", "Read a file from the workspace", schema));
Result call = client.stream(new Request("tiny-coder", List.of(Message.user("Show me pom.xml")), 0.0, 100, tools), t -> { }, new Cancel());
ToolCall tc = call.toolCalls().get(0);
System.out.println("3 tools: finish=" + call.finishReason() + " " + tc.function().name() + " " + tc.function().arguments());
history.add(Message.assistant("", call.toolCalls()));
history.add(Message.tool(tc.id(), "<project>...</project>"));
ObjectMapper snake = new ObjectMapper().setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
System.out.println(" next request ends with " + snake.writeValueAsString(history.subList(2, 4)));
Cancel cancel = new Cancel();
int[] count = {0};
Result stopped = client.stream(new Request("tiny-coder", List.of(Message.user("long")), 0.0, null, null),
t -> { if (++count[0] == 4) cancel.cancel(); }, cancel);
System.out.println("4 cancel: finish=" + stopped.finishReason() + " content=[" + stopped.content() + "]");
try {
client.chat(new Request("tiny-coder", List.of(Message.user("too long")), null, null, null));
} catch (ApiException e) {
System.out.println("5 error: " + e.status + " " + e.type);
}
} finally {
server.stop(0);
}
}
}1 chat: [ Inventweights publishers publishers publishers publishers] finish=length Usage[promptTokens=23, completionTokens=6, totalTokens=29]
2 stream: listener saw | Invent|weights| publishers| finish=length Usage[promptTokens=23, completionTokens=3, totalTokens=26]
3 tools: finish=tool_calls read_file {"path":"src/Main.java" }
next request ends with [{"role":"assistant","content":"","tool_calls":[{"id":"5ztHnIb8odTda96BAIowHvag0ZjNWH7b","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"src/Main.java\" }"}}]},{"role":"tool","content":"<project>...</project>","tool_call_id":"5ztHnIb8odTda96BAIowHvag0ZjNWH7b"}]
4 cancel: finish=cancelled content=[ Inventweights publishers Invent]
5 error: 400 exceed_context_size_errorThe five lines exercise every path of the library:
chatsent a non-streaming request and returned the content,finish=length, and the usage. The request JSON comes fromvalueToTree(request)plus"stream": false, so the records stay free of transport fields.streamcalled the listener once per content delta (| Invent|weights| publishers|), accumulated the same text, and picked up the usage chunk thatinclude_usagerequests. The listener is the hook for a terminal user interface: the chat command prints each piece as it arrives.- With
toolsin the request, the fake server streamed a tool call.streamassembled it by index. The demo then appended the assistant message carryingtool_callsand atoolmessage carrying the sametool_call_id. That is exactly the shape the next request must send, so the model can see the tool result.Messageuses@JsonInclude(NON_NULL), so the assistant message has notool_call_idand the tool message has notool_calls. - The listener cancelled after the fourth delta.
streamchecks the flag before reading each line, leaves the loop, and closes the body stream, which closes the connection as in Example 9. The result hasfinish=cancelled, a value the library invents so that callers can tell a user stop from a server stop, and it holds the text received so far. - A request that the server rejects raised
ApiExceptionwith status 400 and the server's error type.erroralso accepts Ollama's native string form of the envelope.
The design choices and their reasons:
- Unknown fields are ignored (Example 2), and reasoning is read from both
reasoning_contentandreasoning(Example 11), so the same client works with llama-server, vLLM, and Ollama. - SSE parsing is reduced to
data:lines. Blank lines,:pings, andevent:lines are skipped. This is correct for the three engines, which write onedata:line per event (Example 4). A client for other SSE sources needs the full parser from Example 4. - An
errorobject inside the stream becomes anApiException, which covers llama-server's and vLLM's mid-stream errors. - Tool-call arguments stay raw strings in
FunctionCall. The caller parses them after checkingfinishReason, because only the caller knows what to do with an incomplete call (Example 8). - The request timeout is 10 minutes for both methods. For
streamit bounds only the wait for the first chunk.
loop chat in Workshop: loop serve, run, and chat and the coding agent in section 8 build on this class without changing its API.
Interview points
- Chat completions streaming uses server-sent events:
data:lines, a blank line after each event,:comments for keep-alive, and the protocol's owndata: [DONE]terminator, which is not JSON and must be checked before parsing. HttpClient.sendwithBodyHandlers.ofLinesreturns after the headers arrive. The lazyStream<String>then delivers lines while the server is still writing, and closing the stream cancels the exchange.- Each delta carries only new text. The client concatenates
content, takesfinish_reasonfrom a later chunk, and must accept a final usage chunk with an emptychoicesarray. - Tool-call deltas are merged by
index. Theidandnamecome only in the first chunk for an index, and the arguments are parsed only after the stream ends. - Only
finish_reason: "tool_calls"allows running tools. Alengthfinish means the arguments may be cut off in the middle of a string. - Clients must ignore unknown response fields, because every server adds its own fields and new versions add more.
- Omitted sampling fields take server defaults that differ between servers (top_p is 0.95 on llama-server and 1.0 on vLLM and Ollama), so a client sends them explicitly when it needs repeatable behavior.
- A client should retry a 503 (model loading or no free slot) with backoff and jitter, and should not retry 400 context errors or 401. The request timeout bounds the time to the first byte, not the total streaming time.
- Ollama's native API streams NDJSON with nanosecond durations and tool arguments as objects. The Responses API streams named events and has no
[DONE]. - Logprobs near log(1/vocabulary size) mean the model's distribution is almost uniform. With constraints active, the reported top alternatives can be tokens that the grammar did not allow.
Summary
- Jackson records with
SNAKE_CASEnaming andNON_NULLinclusion produce protocol JSON that omits unset fields. - Responses are parsed with
FAIL_ON_UNKNOWN_PROPERTIESturned off, and JSON Pointer (at("/choices/0")) reaches nested fields. - A fake
HttpServeron127.0.0.1:0that replays captured JSON makes client code testable without a model. - SSE events are
data:lines ended by a blank line. Multi-line data is joined with newlines,:lines are comments, andevent:names an event. BodyHandlers.ofLinesstreams lines as they arrive, and chunked transfer encoding carries bodies of unknown length.- A message builder appends
delta.content, recordsfinish_reason, and readsusagefrom the extra chunk thatinclude_usageadds. - Tool calls are rebuilt per
index. Arguments arrive split at token boundaries and are valid JSON only when the call is complete. - Closing the streamed body closes the connection, and llama-server cancels the task when it notices.
- HTTP errors arrive as
{"error": {...}}envelopes, and the errortypedecides between retrying, shortening the prompt, and failing. - llama-server returns
reasoning_content, while vLLM and Ollama returnreasoning. llama-server ends a failed stream without[DONE], and vLLM sends[DONE]after the error event. - Logprobs are natural logarithms of probabilities, and
byteslets a client rebuild characters that were split across tokens. - Ollama's
/api/chatstreams NDJSON withdone,done_reason, and nanosecond metrics, and its errors are{"error": "text"}. - The Responses API streams named events from
response.createdtoresponse.completed(ten events of nine kinds in the capture), usesinput_tokensandoutput_tokensin its usage, and sends no[DONE]. - Project 11's
ChatClientofferschatandstream(listener plus cancel flag), returnsResultrecords, and works with llama-server, vLLM, and Ollama.
