n4nAI

Parsing OpenAI JSON responses in Java with Jackson

A hands-on tutorial for Java engineers parsing OpenAI API JSON responses with Jackson: model the chat completion schema, handle streaming, and avoid pitfalls.

n4n Team3 min read561 words

Audio narration

Coming soon — every post will get a voice note here.

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-databind 2.17.x
  • java.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.

Tagsjavajacksonjsonopenai-api

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All java llm api integration posts →