spring-ai-openai-spring-boot-starter to a Spring Boot 4.1 project’s pom.xml and Maven can’t find it. It isn’t a typo — that artifact was retired at Spring AI 1.0.0-M6 and never resolved against the 2.0 BOM at all. Half the Spring AI tutorials on the web still use it, because they were written against 1.x, and Spring AI 2.0 renamed almost every starter on its way to GA.
This is the other half: the smallest Spring Boot 4.1 application that calls a chat model through ChatClient, using only artifact IDs and property names checked against the Spring AI 2.0.1 jars themselves rather than copied from a tutorial. Every code block below links to a file in a companion repository that compiles and runs; every response you see below is quoted from a captured test run, not typed in by hand.
Versions. Spring Boot 4.1.1 and Spring AI 2.0.1 (GA 12 June 2026, this maintenance release 21 August 2026 — the<release>element in Maven Central’s metadata currently points at4.2.0-M1for Boot, a milestone, not the newest release), on Java 25 (LTS). Spring AI is not on the Spring Boot BOM, so this pair is a compatibility decision you own — the companionpom.xmlpins it explicitly.
What a ChatClient actually wraps
AChatClient is not a network client. It is a fluent builder that assembles a Prompt — your system instructions, your user message, whatever advisors and tools you’ve attached — and hands it to a ChatModel, which is the thing that actually knows how to reach OpenAI, Ollama, or whichever provider you’ve put on the classpath. The autoconfigured ChatClient.Builder Spring hands you is already pointed at a concrete ChatModel bean before your code ever runs; which concrete bean that is comes entirely from properties, which is the whole subject of this article.
The smallest ChatClient that compiles
Two dependencies and one bean. The starter naming is the first thing 1.x tutorials get wrong on Boot 4 —spring-boot-starter-web itself is deprecated in Boot 4 in favour of spring-boot-starter-webmvc, since WebFlux now has an equally-named sibling starter.
<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>
Full file with the BOM import and versions: getting-started/pom.xml.
One bean turns the autoconfigured builder into the ChatClient your controller injects:
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder
.defaultSystem("""
You are a terse Java and Spring assistant. Answer in at most two \
sentences unless the user asks for code.""")
.build();
}
Source: ChatClientConfig.java. And the endpoint that uses it is the entire method body:
@GetMapping("/api/chat")
public String chat(@RequestParam String message) {
return this.chatClient.prompt()
.user(message)
.call()
.content();
}
Source: ChatController.java. A real request against it, from the test suite (the model here is a scripted stand-in explained two sections down — what this proves is that the call reaches it and the response comes back through ChatClient intact):
$ curl 'http://localhost:34201/api/chat?message=What+package+is+ChatClient+in?'
You said: "What package is ChatClient in?". That is 30 characters.
Output: 01-plain-call.txt.
Going deeper: the full OpenAI connection property reference
Beyond spring.ai.openai.api-key, the properties that matter in practice:
| Property | Default | Notes |
|---|---|---|
spring.ai.model.chat | — | the provider switch this article is about; see below |
spring.ai.openai.base-url | https://api.openai.com | override for an OpenAI-compatible gateway |
spring.ai.openai.chat.model | gpt-5-mini | this repo pins gpt-4o explicitly |
spring.ai.openai.chat.temperature | not sent | this repo sets 0.1 for repeatability; see note below |
spring.ai.openai.chat.max-tokens | — | non-reasoning models only |
spring.ai.openai.chat.max-completion-tokens | — | reasoning models (o1/o3); mutually exclusive with max-tokens |
The 1.x property spring.ai.openai.chat.enabled is gone outright in 2.0, not deprecated-and-still-working — spring.ai.model.chat replaces it, and because Spring Boot silently ignores configuration keys it doesn’t recognise, a leftover chat.enabled: false from a 1.x config does nothing at all rather than failing loudly. This module’s full application.yml: application.yml.
Both defaults above are easy to get wrong from the docs page alone, so they were checked against the actual class files in spring-ai-openai-2.0.1.jar with javap -p -c rather than taken on faith. OpenAiChatOptions has a real static default for the model — its static initializer reads com.openai.models.ChatModel.GPT_5_MINI.asString() into a DEFAULT_CHAT_MODEL constant, and the constructor falls back to it whenever no model is supplied. Temperature gets no such treatment: the constructor stores whatever was passed in, including null, with no fallback constant anywhere in OpenAiChatOptions. Following it one level further, into OpenAiChatModel‘s request-building code, shows why that matters — the bytecode is an explicit ifnull guard around the call to ChatCompletionCreateParams.Builder.temperature(...), so when temperature is null the parameter is left off the request to OpenAI entirely, rather than a Spring-side default being substituted. In other words, an unset spring.ai.openai.chat.temperature doesn’t mean 0.8, or any other Spring AI default — it means OpenAI’s own API applies whatever it applies server-side, which is a fact about OpenAI’s API, not about Spring AI.
System and user prompts, and a concatenation gotcha the docs don’t mention
The fluent API takes system and user text either as plain strings or as a lambda that fills a template:@GetMapping("/api/chat/as")
public String chatAs(@RequestParam String voice, @RequestParam String message) {
return this.chatClient.prompt()
.system(s -> s.text("Answer in the voice of a {voice}, still in two sentences.")
.param("voice", voice))
.user(message)
.call()
.content();
}
Source: same ChatController.java, method chatAs. A real run, including the exact text the model received — captured by asserting on the scripted model’s recorded prompts, not just the response:
$ curl 'http://localhost:34201/api/chat/as?voice=pirate&message=Where+is+my+jar+cached'
Response body:
As a pirate: Where is my jar cached -- yes, and it is exactly 22 characters long.
Exact text FakeChatModel received (proves the {voice} placeholder was substituted by ChatClient before the ChatModel ever saw the prompt):
Answer in the voice of a pirate, still in two sentences.Where is my jar cached
Output: 02-system-template.txt. Look closely at that last line: ...still in two sentences.Where is my jar cached — no space, no newline, the system sentence’s final period runs straight into the user text. Prompt#getContents() joins every message’s text with no separator at all. It cost a wrong assertion while writing this module’s tests to find that out; the fix was to search for this module’s own known system-prompt suffix rather than assume a blank line, in FakeChatModel.java.
The fingerprint of this bug in a real system prompt: if your system instructions end without terminal punctuation and a space — a trailing colon, an open template variable — the first word of the user’s message will visually fuse onto it in anything that logs the raw prompt text. The model still receives it as one continuous string either way; getContents() is not what the provider API sends over the wire (each message stays a separate JSON object there), it is only Spring AI’s own debug-and-logging view of the prompt.
Going deeper: the rest of the prompt-building API
Per the Spring AI reference docs (doc-sourced, not independently re-verified against the jar beyond compiling this repo’s own .system(...) and .user(...) calls against it): .user(...) takes the same string-or-lambda shape as .system(...), and a user spec also accepts .metadata(key, value) pairs that ride along with the message without becoming part of the text sent to the model — useful for a request ID or a user ID your own advisors want to read later. Builder-level defaults — .defaultSystem(...), .defaultUser(...), .defaultAdvisors(...) — are set once on the ChatClient.Builder in ChatClientConfig.java and apply to every .prompt() call from that client, which is exactly what this module does with .defaultSystem(...) already, above. Custom template delimiters (useful if your prompt text legitimately contains literal {curly braces}, which would otherwise be read as a parameter) are set per-call with .templateRenderer(StTemplateRenderer.builder().startDelimiterToken('<').endDelimiterToken('>').build()) — not exercised in this module, since none of its prompts contain a literal brace.
Streaming, and a default that throws instead of falling back
The streaming call looks like the natural extension of.call():
@GetMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String message) {
return this.chatClient.prompt()
.user(message)
.stream()
.content();
}
Source: ChatController.java, method chatStream. The first version of this module’s test double didn’t override stream() at all, on the assumption that ChatModel would fall back to wrapping call() in a single-element Flux the way several other Spring interfaces provide sensible defaults. It does not:
java.lang.UnsupportedOperationException: streaming is not supported
at org.springframework.ai.chat.model.ChatModel.stream(ChatModel.java:65) ~[spring-ai-model-2.0.1.jar:2.0.1]
at org.springframework.ai.chat.client.advisor.ChatModelStreamAdvisor.adviseStream(ChatModelStreamAdvisor.java:53) ~[spring-ai-client-chat-2.0.1.jar:2.0.1]
Output: 06-stream-not-supported-original-failure.txt. ChatModel#stream(Prompt)’s default implementation throws, unconditionally — any ChatModel that supports streaming, including every real provider integration, has to override it explicitly. The fix in this repo’s test double is three lines:
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return Flux.just(call(prompt));
}
Source: FakeChatModel.java. With that in place, the endpoint streams — one chunk, honestly, since the stand-in doesn’t tokenize; a real provider streams token by token:
$ curl -N 'http://localhost:34201/api/chat/stream?message=Stream+this'
Chunks received: 1
Joined: You said: "Stream this". That is 11 characters.
Output: 03-streaming.txt.
If you write your own ChatModel or a test double for one: implementingcall()alone compiles fine — the interface doesn’t force you to implementstream()— and then fails at runtime, at the call site, with a message that gives no hint it’s about a missing override. It surfaces as a 500 from whatever your streaming endpoint returns, not as a wiring error at startup.
Going deeper: what you get back from call() and stream()
Per the reference docs, both .call() and .stream() offer more than .content():
| Method chain | Returns |
|---|---|
.call().content() | String |
.call().chatResponse() | ChatResponse — token usage, finish reason, model metadata |
.call().entity(MyRecord.class) | the reply parsed straight into a Java record |
.stream().content() | Flux<String> |
.stream().chatResponse() | Flux<ChatResponse> |
The .entity(...) path is worth its own article once you’re past this one — it’s what Structured Output in Spring AI 2.0 (later in this series) covers, including the retry behaviour when the model returns JSON that doesn’t match the schema.
Switching providers with one property
This is the part worth building the whole module around: with two provider starters on the classpath at once and zero mentions of either provider’s class name inChatClientConfig or ChatController, one property decides which ChatModel gets built.
spring.ai.model.chat=openai # -> org.springframework.ai.openai.OpenAiChatModel
spring.ai.model.chat=ollama # -> org.springframework.ai.ollama.OllamaChatModel
Both values of that same property live in this module’s application.yml (as CHAT_PROVIDER, its environment-overridable default). Proving that mechanically, rather than just asserting it in prose, means never contacting either provider’s server — only inspecting which bean class Spring actually assembled under each property value, using Boot’s own ApplicationContextRunner test utility:
runner.withPropertyValues("spring.ai.model.chat=openai", ...)
.run(context -> {
ChatModel model = context.getBean(ChatModel.class);
// model.getClass() is OpenAiChatModel
});
Source: ProviderSwitchTest.java. The real output, both branches from the same test run:
# Which ChatModel class spring.ai.model.chat selects
spring.ai.model.chat=openai -> org.springframework.ai.openai.OpenAiChatModel
spring.ai.model.chat=ollama -> org.springframework.ai.ollama.OllamaChatModel
Output: 04-provider-switch.txt.
Going deeper: what it took to make that test pass
Neither autoconfiguration class builds in isolation, and ApplicationContextRunner only activates the autoconfigurations you explicitly list — unlike a full Boot application, where every entry in every AutoConfiguration.imports file on the classpath is a candidate. Two real failures while writing this test, both from context-startup stack traces rather than guesswork:
OpenAiChatAutoConfiguration autowires a ToolCallingManager constructor argument, so it needs org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration included alongside it, even though this module never calls a tool.
OllamaChatAutoConfiguration depends on an OllamaApi bean, which its sibling OllamaApiAutoConfiguration provides — the two classes live in the same jar and a real Boot app pulls both in for free.
The final working set, and the exact package each class lives in (found by unzipping the autoconfigure jars, not guessed): org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration, org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration, org.springframework.ai.model.ollama.autoconfigure.OllamaApiAutoConfiguration, org.springframework.ai.model.ollama.autoconfigure.OllamaChatAutoConfiguration. All four together: ProviderSwitchTest.java.
Proof the wiring reaches a real provider
Every number above comes from a scripted stand-in, on purpose — a test suite that needed a paid API key to pass would not run in CI, and would not run for a reader without one either. But it’s worth showing, once, that the sameChatClient bean really does reach OpenAI’s own servers when a real key is missing rather than just plausible: run the full application with a syntactically valid but fake key, and call the plain endpoint.
$ export OPENAI_API_KEY=sk-test-placeholder-not-a-real-key
$ curl 'http://localhost:8080/api/chat?message=hi'
Full transcript: 05-real-network-round-trip.txt. Response: HTTP 500, body {"timestamp":"2026-09-23T03:53:37.888Z","status":500,"error":"Internal Server Error","path":"/api/chat"} (05-real-openai-401-response-body.txt). The application log’s root cause, grepped from the running process, not paraphrased:
com.openai.errors.UnauthorizedException: 401: Incorrect API key provided: sk-test-**********************-key. You can find your API key at https://platform.openai.com/account/api-keys.
Output: 05-real-network-round-trip.txt and 05-real-openai-401-response-body.txt. That com.openai.errors.UnauthorizedException is the OpenAI Java SDK’s own exception class, thrown after OpenAI’s server validated the key format and rejected it — not a Spring AI error, not a DNS failure, not a timeout. Put a real key in that same environment variable and this becomes a real reply; nothing else about the wiring changes.
What this ten-minute build leaves out
A singleChatClient bean with no advisors is the honest floor, not the finished picture. None of the following is configured here:
- Tool calling — letting the model call your own Java methods. Next in this series:
@Tool,ToolCallingAdvisor, and the Tool Search Advisor for picking a tool out of a large registry. - Structured output — mapping a reply straight into a Java record with automatic retry when the model returns malformed JSON.
- Chat memory — every call above is stateless; there’s no
MessageChatMemoryAdvisor, so a follow-up question gets no context from the one before it. - MCP — exposing this same service’s endpoints as tools an external agent (or Claude Desktop) can call, and the reverse: calling someone else’s MCP server as a tool source.
- Security — nothing here authenticates a caller or scopes what a tool is allowed to touch, which matters the moment any of the above is wired to something that can write.
- Running for free — every exhibit here needs an OpenAI key or a scripted stand-in; a local Ollama model needs neither.
Should you even reach for ChatClient for a one-off script? If the whole job is “send one prompt, print the reply,” a bare HTTP call to the provider’s API is fewer moving parts and one fewer dependency to keep compatible with the Boot BOM. ChatClient earns its keep once there’s more than one call site, once you want the same code to work against more than one provider (this article’s whole subject), or once advisors, memory, or tools enter the picture — which is most real applications, but genuinely not every script.
Every Spring AI article on this site
| Article | Covers |
|---|---|
| Spring AI 1.x to 2.0: The Migration Guide | what breaks, and what breaks silently, upgrading an existing 1.x application |
| Production-Grade RAG with Spring AI | chunking, ingestion, retrieval, reranking, and a faithfulness check against pgvector |
| Spring AI RAG in Java: Complete Code Tour | the same RAG project, file by file |
| Vector Embeddings and Semantic Search in Pure Java | the mechanics of embeddings and cosine similarity, without Spring AI — useful background before the RAG articles |
Further reading
- Companion repository for this article: asmhatre/spring-ai, getting-started module
- Official reference: Spring AI – Chat Client API
- Official reference: Spring AI – OpenAI Chat
- Release notes: Spring AI 2.0.1 Available Now
No Comments yet!