When you call the OpenAI chat completions endpoint from a JVM service, you need a reliable way to turn the HTTP body into typed objects. This tutorial walks through practical java jackson openai json parsing for the chat completion schema, covering nested choices, token usage, and streaming chunks without pulling in a vendor SDK. You will write a small client that deserializes real response shapes and prints structured fields.
Prerequisites
- Java 17 or newer (records keep the models concise)
- Maven or Gradle for dependency management
com.fasterxml.jackson.core:jackson-databind2.17.xjava.net.http.HttpClient(built into the JDK) or OkHttp 4- A valid OpenAI API key, or any OpenAI-compatible endpoint URL
If you use Maven, add this to pom.xml:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.2</version>
</dependency>
Gradle users add implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'.
The JSON contract
A non-streaming chat/completions response looks like this (trimmed):
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1715000000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "The capital of France is Paris."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 8,
"total_tokens": 20
}
}
Streaming returns one JSON object per data: line with a choices[0].delta instead of message.
Model the response with Jackson
Use Java records. Jackson binds JSON property names to constructor parameters via @JsonProperty. Keep unknown fields ignored at the mapper level.
import com.fasterxml.jackson.annotation.JsonProperty;
public record ChatCompletion(
String id,
String object,
long created,
String model,
List<Choice> choices,
Usage usage,
@JsonProperty("system_fingerprint") String systemFingerprint
) {}
public record Choice(
int index,
Message message,
@JsonProperty("finish_reason") String finishReason,
Delta delta
) {}
public record Message(
String role,
String content,
@JsonProperty("tool_calls") List<ToolCall> toolCalls
) {}
public record Delta(
String role,
String content
) {}
public record Usage(
@JsonProperty("prompt_tokens") int promptTokens,
@JsonProperty("completion_tokens") int completionTokens,
@JsonProperty("total_tokens") int totalTokens
) {}
public record ToolCall(
String id,
String type,
FunctionCall function
) {}
public record FunctionCall(
String name,
String arguments
) {}
Note that Choice has both message (for non-streaming) and delta (for streaming). Only one is populated per response type. That is fine; Jackson leaves the other null.
Configure ObjectMapper
Disable unknown property failures so provider additions don’t break your client:
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
ObjectMapper mapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false);
Parse a synchronous response
Here is a minimal blocking call using the JDK client:
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.URI;
String apiKey = System.getenv("OPENAI_API_KEY");
String body = """
{
"model": "gpt-4o-mini",
"messages": [{"role":"user","content":"What is the capital of France?"}]
}
""";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://api.openai.com/v1/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> res = client.send(req, HttpResponse.BodyHandlers.ofString());
ChatCompletion completion = mapper.readValue(res.body(), ChatCompletion.class);
Expected output after inspection
Print the assistant text and token count:
String answer = completion.choices().get(0).message().content();
System.out.println("Answer: " + answer);
System.out.println("Total tokens: " + completion.usage().totalTokens());
Running this against the live API prints something like:
Answer: The capital of France is Paris.
Total tokens: 20
Parsing tool calls
When the model invokes a function, message.tool_calls appears. Sample:
{
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": "call_abc",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"loc\":\"Paris\"}"}
}]
}
}]
}
Our ToolCall record captures it. Print the invoked names:
completion.choices().stream()
.flatMap(c -> c.message().toolCalls().stream())
.forEach(tc -> System.out.println(tc.function().name()));
Output: get_weather.
Parse streaming chunks
For streaming, set "stream": true and read the response body line by line. Each line starts with data: and ends with \n. The delta field carries incremental content. The same java jackson openai json parsing approach works for chunks because the top-level shape is identical except choices[0].message is null and choices[0].delta is present.
Read the stream:
HttpRequest streamReq = HttpRequest.newBuilder()
.uri(URI.create("https://api.openai.com/v1/chat/completions"))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(
"""
{
"model": "gpt-4o-mini",
"stream": true,
"messages": [{"role":"user","content":"Count to three."}]
}
"""))
.build();
HttpResponse<java.io.InputStream> streamRes = client.send(
streamReq, HttpResponse.BodyHandlers.ofInputStream());
try (var reader = new java.io.BufferedReader(
new java.io.InputStreamReader(streamRes.body()))) {
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("data: ")) {
String json = line.substring(6).trim();
if (json.equals("[DONE]")) break;
ChatCompletion chunk = mapper.readValue(json, ChatCompletion.class);
String delta = chunk.choices().get(0).delta().content();
if (delta != null) System.out.print(delta);
}
}
}
Checkpoint output
The streamed printout concatenates deltas:
One
Two
Three
No final newline unless the model emits one.
Handle errors gracefully
OpenAI returns a flat error object on non-2xx:
{
"error": {
"message": "Invalid authentication",
"type": "invalid_request_error",
"code": "invalid_api_key"
}
}
Add a small error model and check status code before parsing success:
public record ApiError(
@JsonProperty("error") ErrorDetail error
) {}
public record ErrorDetail(String message, String type, String code) {}
if (res.statusCode() != 200) {
ApiError err = mapper.readValue(res.body(), ApiError.class);
throw new RuntimeException("API error: " + err.error().message());
}
Using an OpenAI-compatible gateway
If you point your HTTP client at an OpenAI-compatible endpoint such as n4n.ai, the JSON contract is identical, so the Jackson models above work without modification. The only change is the base URL and possibly a header for routing or cache control. Because the gateway honors client routing directives and forwards provider cache-control hints, you can send the same messages payload and parse the response with the same ChatCompletion class.
Example with a different base:
URI.create("https://api.n4n.ai/v1/chat/completions")
No model changes required.
Dealing with provider-specific extensions
Some models return system_fingerprint or extra fields in usage like completion_tokens_details. Because we disabled FAIL_ON_UNKNOWN_PROPERTIES, those fields are ignored. If you need them, extend the record:
public record Usage(
@JsonProperty("prompt_tokens") int promptTokens,
@JsonProperty("completion_tokens") int completionTokens,
@JsonProperty("total_tokens") int totalTokens,
@JsonProperty("completion_tokens_details") Map<String,Object> completionTokensDetails
) {}
Jackson binds the extra object to a map with no further code.
Testing without the network
Validate your models against a saved payload:
ChatCompletion test = mapper.readValue(
new java.io.File("src/test/resources/sample.json"), ChatCompletion.class);
assert test.model().equals("gpt-4o-mini");
This catches mapping regressions before deploy.
Final notes on java jackson openai json parsing
Keep your models narrow: only declare fields you actually read. Adding every optional field invites breakage when providers rename. Use FAIL_ON_UNKNOWN_PROPERTIES=false as a default for any external LLM gateway. For high-throughput services, reuse a single ObjectMapper and HttpClient instance; both are thread-safe.
The full pattern—records for shape, lenient mapper, status-code check, and line-based streaming—covers the majority of java jackson openai json parsing needs in production JVM services.