You don’t need a heavyweight SDK to talk to LLMs. This tutorial shows how to call the java httpclient openai api directly using the standard library’s HttpClient, giving you full control over timeouts, retries, and request shaping. We’ll build a minimal chat completion client from scratch and print real responses.
Prerequisites
- Java 17 or later (the
java.net.httpmodule is stable and ships with the JDK) - An OpenAI API key exported as
OPENAI_API_KEY - Jackson Databind 2.17+ on the classpath for JSON mapping
- Comfort with Java records, lambdas, and
try-with-resources
No Spring, no OpenAI Java SDK. Just the JDK and one JSON library.
Project Setup
If you use Maven, add this to pom.xml:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
Gradle users:
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
Compile a single file with:
javac -cp jackson-databind-2.17.1.jar:. OpenAIClient.java
java -cp jackson-databind-2.17.1.jar:. OpenAIClient
Building the Request
The java httpclient openai api interaction is a plain HTTP POST to /v1/chat/completions. We construct the JSON body inline for clarity, then wrap it in a HttpRequest.
import java.net.http.*;
import java.net.URI;
import java.nio.charset.StandardCharsets;
public class OpenAIClient {
static final String ENDPOINT = "https://api.openai.com/v1/chat/completions";
static final String API_KEY = System.getenv("OPENAI_API_KEY");
public static void main(String[] args) throws Exception {
String body = """
{
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say hello in one word."}],
"temperature": 0.2
}
""";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(ENDPOINT))
.header("Authorization", "Bearer " + API_KEY)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
}
}
Run it. Expected output (truncated):
200
{"id":"chatcmpl-abc123","object":"chat.completion","created":1710000000,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"Hello"},"finish_reason":"stop"}]}
That is the minimal java httpclient openai api integration. It works, but printing raw JSON is not production-ready.
Dynamic Request Bodies
Hard-coded JSON strings break fast. Use Jackson to serialize a Map so you can inject user input safely:
var payload = Map.of(
"model", "gpt-4o-mini",
"messages", java.util.List.of(
Map.of("role", "user", "content", "Say hello in one word.")
)
);
String body = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(payload);
This avoids manual escaping and lets you build message lists programmatically.
Parsing the Response
Define records that match the response subset you care about:
record Message(String role, String content) {}
record Choice(Message message, String finish_reason) {}
record ChatCompletion(String id, String model, java.util.List<Choice> choices) {}
Map and extract:
import com.fasterxml.jackson.databind.ObjectMapper;
ObjectMapper mapper = new ObjectMapper();
ChatCompletion completion = mapper.readValue(response.body(), ChatCompletion.class);
String answer = completion.choices().get(0).message().content();
System.out.println("Model " + completion.model() + " says: " + answer);
Output:
Model gpt-4o-mini says: Hello
Timeouts and Retries
HttpClient defaults to no connect timeout. Always set one:
HttpClient client = HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(10))
.build();
For transient failures, implement a simple retry loop. OpenAI returns 429 (rate limit) and 5xx (upstream errors) that are worth retrying.
int maxRetries = 3;
HttpResponse<String> resp = null;
for (int i = 0; i < maxRetries; i++) {
resp = client.send(request, HttpResponse.BodyHandlers.ofString());
int code = resp.statusCode();
if (code == 200) break;
if (code == 401 || code == 400) throw new RuntimeException("Fatal: " + resp.body());
long backoff = (long) Math.pow(2, i) * 1000;
Thread.sleep(backoff);
}
In real systems, read the Retry-After header and add jitter. The java httpclient openai api call should fail fast on auth errors, not loop.
Streaming Responses
Non-streaming blocks until the model finishes. For chat UIs, stream tokens with Server-Sent Events. Set Accept: text/event-stream and stream: true in the body.
HttpRequest streamReq = HttpRequest.newBuilder()
.uri(URI.create(ENDPOINT))
.header("Authorization", "Bearer " + API_KEY)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString("""
{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Count to 5."}],"stream":true}
"""))
.build();
HttpResponse<java.io.InputStream> streamResp = client.send(streamReq, HttpResponse.BodyHandlers.ofInputStream());
ObjectMapper mapper = new ObjectMapper();
try (var reader = new java.io.BufferedReader(new java.io.InputStreamReader(streamResp.body()))) {
reader.lines().forEach(line -> {
if (!line.startsWith("data:")) return;
String data = line.substring(5).trim();
if (data.equals("[DONE]")) return;
try {
var node = mapper.readTree(data);
var delta = node.path("choices").get(0).path("delta").path("content").asText();
if (!delta.isEmpty()) System.out.print(delta);
} catch (Exception e) { /* skip malformed frame */ }
});
}
You will see tokens appear incrementally instead of one blocked response.
Using an OpenAI-Compatible Gateway
The same java httpclient openai api code works against any endpoint that speaks the OpenAI protocol. Swap ENDPOINT to https://api.n4n.ai/v1/chat/completions and the request shape stays identical. n4n.ai fronts 240+ models and automatically falls back when a provider is rate-limited, while metering per token and honoring your cache-control headers. That gives you resilience without writing your own fallback branching.
Production Notes
- Store keys in env vars or a secret manager. Never embed them in source.
- Prefer
sendAsyncfor high concurrency; it returnsCompletableFuture<HttpResponse<T>>and avoids thread-per-request blocking. - Map status codes explicitly:
401means bad key,429means throttle,5xxmeans upstream issue. - Always close the
InputStreamfrom streaming responses (thetry-with-resourcesabove handles it). - For structured output, add
"response_format": {"type": "json_object"}to the body and validate the returned string.
The standard library client keeps your dependency tree small and your latency predictable. You now have a working Java LLM client without a vendor SDK.