Sooner or later someone asks whether the app can “just call ChatGPT”.
You can do it by hand. Build a JSON body, put the API key in a header, post it to OpenAI with a
RestClient, and dig the answer out of the JSON that comes back. It works, but now your code knows
OpenAI’s request format, its response format, and its error format, and every feature you add later
grows that pile.
Spring AI lets you skip all of that. You write a prompt, you get text back, and Spring Boot handles the
wiring. In this post we’ll walk through a complete, very small app that does exactly that: one REST
endpoint that takes a prompt, sends it to OpenAI’s gpt-5, and returns the answer. The whole
project has two Java classes and five lines of active configuration, so we can go through every line of it.
Sample code: github.com/Jitu7/Spring-AI/tree/main/chat-openai
Built with Spring Boot
4.1.2, Spring AI2.0.1and Java27.
What we’re building
Here’s the whole journey of a request, from the caller to OpenAI and back:
POST /api/chat {"prompt": "..."}
-> ChatController checks the prompt isn't blank
-> ChatClient the Spring AI API we call
-> SimpleLoggerAdvisor logs the request and the response
-> OpenAI chat model turns it into an HTTP call to OpenAI
<- {"content": "..."} the model's answer, sent back as JSON
We write the first line and the last line. Spring AI provides everything in between. Let’s start with how it gets onto the classpath.
The dependencies
The project needs three starters: one for the web layer, one for validation, and one for OpenAI:
<properties>
<java.version>27</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Notice that spring-ai-starter-model-openai has no <version>. That’s the job of the
spring-ai-bom in dependencyManagement. A BOM (bill of materials) is a list of versions that are
known to work together. You import it once, set spring-ai.version once, and every Spring AI
dependency you add later gets a matching version. When you upgrade Spring AI, you change one number.
The other two starters come from Spring Boot itself. If you’re used to Spring Boot 3, the name
spring-boot-starter-webmvc may look new. It’s what Spring Boot 4 calls the starter for classic
Spring MVC controllers, the job spring-boot-starter-web used to do.
The configuration
This is the project’s
application.properties,
with three comment lines trimmed:
spring.application.name=chat-openai
#spring.ai.openai.base-url=https://api.openai.com
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.model=gpt-5
spring.ai.openai.chat.temperature=1
logging.level.org.springframework.ai.chat.client.advisor=DEBUG
Let’s take these one at a time.
spring.ai.openai.api-key=${OPENAI_API_KEY} is the secret that identifies your OpenAI account.
The ${...} syntax tells Spring to read it from an environment variable called OPENAI_API_KEY
when the app starts. The key itself never appears in the file, so it never ends up in Git. Please
keep it that way. A leaked key gets used by someone else, and you get the bill.
spring.ai.openai.base-url is commented out, and that’s fine. It’s the address Spring AI sends
requests to, and it already points at OpenAI by default. You’d only uncomment it to send traffic
somewhere else, such as a company proxy in front of OpenAI.
spring.ai.openai.chat.model=gpt-5 picks the model. This is the line you’ll change most often,
because models differ a lot in price, speed and quality. If you leave it out, the Spring AI docs say
you get gpt-5-mini. I’d always set it explicitly anyway, so that anyone reading the config knows
which model the bill comes from.
spring.ai.openai.chat.temperature=1 needs a bit more explanation. Temperature controls how
random the model’s answers are. Lower values give you more predictable, repeatable answers, and
higher values give you more varied ones. For most models it’s a useful dial.
GPT-5 is different. It’s a reasoning model, which works through a problem before it answers, and
it doesn’t let you change the temperature. It only accepts its default, 1. That’s why this project
sets exactly 1. The Spring AI docs are stricter: for gpt-5, gpt-5-mini and gpt-5-nano they
say temperature isn’t supported, and that setting it results in an error. With 1 it didn’t. The
request in Running it below went through with this exact configuration.
Don’t expect Spring AI to catch a wrong value, though. It passes whatever temperature you configure
straight to OpenAI, without checking which model you picked. And if you leave the property out, it
sends no temperature at all, so OpenAI uses the model’s default anyway. So temperature=1 works, but
it adds nothing, and it invites someone to “tune” it to 0.2 later and break every request. I’d
delete it, and add it back when you switch to a model that does support temperature, such as
gpt-5-chat.
Note: the property table in the Spring AI docs lists 0.8 as the default temperature. That’s not
what happens. The property has no default, which is why leaving it out is safe.
logging.level.org.springframework.ai.chat.client.advisor=DEBUG turns on logging for the
SimpleLoggerAdvisor we’ll meet in the controller. Remember this one. Without it, the advisor still
runs, but you won’t see anything it logs.
Notice what isn’t here: any code that creates an OpenAI client. Because the OpenAI starter is on the
classpath and these properties are set, Spring Boot’s autoconfiguration creates the OpenAI chat model
and a ChatClient.Builder for us at startup.
The main class
The application class is the usual Spring Boot entry point, with one detail worth a second look:
@SpringBootApplication
public class ChatOpenaiApplication {
static void main(String[] args) {
SpringApplication.run(ChatOpenaiApplication.class, args);
}
}
main isn’t public. For most of Java’s history that wouldn’t run at all. Since Java 25 (JEP 512),
the launcher no longer requires main to be public, so on Java 27 this starts normally. If you copy
this class into a project on Java 21, add the public back.
The controller
This is where our code meets Spring AI. Here’s the full
ChatController:
@RestController
@RequestMapping("/")
class ChatController {
private final ChatClient chatClient;
ChatController(ChatClient.Builder builder) {
this.chatClient = builder
.defaultAdvisors(new SimpleLoggerAdvisor())
.build();
}
@PostMapping("/api/chat")
Output chat(@RequestBody @Valid Input input) {
String response = chatClient.prompt(input.prompt()).call().content();
return new Output(response);
}
record Input(@NotBlank String prompt) {}
record Output(String content) {}
}
Don’t let how short it is fool you. There’s quite a lot happening here, so let’s go through it in three parts.
Building the ChatClient
ChatClient is the main thing you use in Spring AI. It’s a fluent API for talking to a chat model,
in the same spirit as RestClient or WebClient. You don’t create one with new. Instead, you ask
Spring for a ChatClient.Builder, set it up, and call build().
We do that once, in the constructor, and keep the result in a final field. Every request then
reuses the same client. Building a new one on each request would work, but there’s no reason to do
it.
The builder Spring gives us is prototype-scoped: every class that asks for a ChatClient.Builder
gets its own new instance. That’s what makes it safe to customise. If another controller asked for a
builder and set different defaults, the two wouldn’t affect each other.
The one customisation here is defaultAdvisors(new SimpleLoggerAdvisor()). An advisor is code
that runs around every call to the model, a bit like a servlet filter runs around every HTTP
request. Advisors can change the request, inspect the response, or add things like chat memory.
SimpleLoggerAdvisor is the simplest one there is: it logs what goes in and what comes out. Because
we added it through defaultAdvisors, it applies to every call this client makes.
The one line that calls OpenAI
String response = chatClient.prompt(input.prompt()).call().content();
This reads like one action, but it’s three steps:
prompt(input.prompt())starts a new request and uses the caller’s text as the user message.call()says we want a normal, blocking call that waits for the full answer. The alternative isstream(), which hands you the answer piece by piece as the model writes it.content()actually sends the request, waits for the reply, and returns the answer as aString.
The surprising part is step 2. call() doesn’t contact OpenAI. Nothing goes over the network until
content() runs. If you need more than the text, such as metadata about the response, Spring AI has
other terminal methods, for example chatResponse(), which returns the whole response object.
Input, output and validation
The request and response bodies are two small Java records, nested inside the controller because nothing else uses them:
record Input(@NotBlank String prompt) {}
record Output(String content) {}
Spring turns {"prompt": "..."} into an Input, and turns our Output back into
{"content": "..."}. Records are a good fit here: they’re immutable, need no getters, and the
JSON field names come straight from the component names.
@NotBlank on prompt, together with @Valid on the method parameter, means an empty or
whitespace-only prompt is rejected before our method even runs. That matters more here than in a
normal endpoint, because every request that does reach OpenAI costs money. There’s no point paying
for a call with nothing in it.
One small note: @RequestMapping("/") on the class adds no prefix, so the endpoint is simply
/api/chat. You could remove it and nothing would change.
Running it
You need JDK 27, since that’s what java.version targets, and an OpenAI API key. Then:
export OPENAI_API_KEY=sk-...
./mvnw package -DskipTests
java -jar target/chat-openai-0.0.1-SNAPSHOT.jar
Once it says Started ChatOpenaiApplication, send it a prompt:
curl -X POST localhost:8080/api/chat \
-H 'Content-Type: application/json' \
-d '{"prompt":"In one sentence, what is Spring AI?"}'
The answer comes back as JSON with a single content field:
{"content":"Spring AI is a Spring project that provides a unified, Spring Boot–friendly programming model and integrations for LLMs, embeddings, and related AI services (e.g., OpenAI, Azure, Bedrock, Hugging Face, Ollama), including features like prompt templating, function calling, and vector-store support."}
Your answer will be worded differently. The model doesn’t give the same reply twice.
Now look at the application log. Because of the advisor and the DEBUG setting, each request is
logged (trimmed here):
DEBUG ... o.s.a.c.c.advisor.SimpleLoggerAdvisor : request: ChatClientRequest[prompt=Prompt{messages=[UserMessage{content='In one sentence, what is Spring AI?', metadata={messageType=USER}, messageType=USER}], modelOptions=org.springframework.ai.openai.OpenAiChatOptions@dd8a9d8e}, context={}]
You can see the prompt went in as a UserMessage. The advisor logs the response too, once one comes
back. One limitation to know about: modelOptions prints as an object reference
(OpenAiChatOptions@dd8a9d8e), so this log won’t tell you which model or temperature was used. For
that, trust your application.properties.
Finally, check the validation by sending a blank prompt:
curl -X POST localhost:8080/api/chat \
-H 'Content-Type: application/json' \
-d '{"prompt":" "}'
{"timestamp":"2026-11-07T10:57:39.167Z","status":400,"error":"Bad Request","path":"/api/chat"}
A 400 Bad Request, and no request was sent to OpenAI, so no cost.
Summary
That’s a complete Spring AI app: one starter, a handful of properties and one controller. If you’re
starting your own, I’d copy this shape. Inject ChatClient.Builder, build the client once in the
constructor, and keep SimpleLoggerAdvisor on while you develop so you can see what’s going to the
model. Keep the API key in an environment variable, always set the model explicitly, and leave
temperature out while you’re on GPT-5. And validate input before it reaches the model, because
that’s the cheapest request you’ll ever make.
