OrderDesk, the support assistant of our demo online store, runs on a small local model in Ollama. The local model is free, but it sometimes gets simple facts wrong. In this example, we add Anthropic’s Claude to the same app, and the caller picks the model for each question. We show the requests Spring AI sends to Claude, but no Claude answer, because we didn’t run it with a real API key. We’ll use Spring AI 2.0.
1. One Interface, Many Model Providers
A local model and a hosted model like Claude do the same job: they take messages and return an answer. They differ in three ways: where they run, what they cost, and how good the answers are.
- A local model runs on your own machine. It costs nothing per request, and your data never leaves the machine. But small local models make more mistakes.
- A hosted model runs at the provider. You pay for every token (a token is a piece of a word). Your prompts also travel over the internet to the provider. In return, the answers are usually much better.
Many apps use both: the cheap model for simple questions and the strong model where quality matters.
If every provider had a different Java API, you’d have to rewrite your code when switching providers. Spring AI avoids this by defining a common interface called ChatModel. Each provider module implements this interface with its own class, such as OllamaChatModel or AnthropicChatModel. ChatClient provides a consistent API for interacting with a ChatModel, so your controller can send a question through ChatClient without depending directly on a particular provider.
This means the code that sends a question can stay the same when you switch providers, as long as the new provider supports the features your application needs. Here’s what that looks like:

The Spring AI chat model overview lists the supported providers. The rest of this article covers the Anthropic parts: the dependency, the API key, the properties, and Claude’s extended thinking.
2. Setting Up the App
The example is a Spring Boot 4.1 app called orderdesk-anthropic, and it uses Spring AI 2.0.1 and Java 21. You can find the project in the spring-ai-examples repository on GitHub. If you need help installing Ollama and downloading the qwen2.5:3b model, follow the Getting Started with Spring AI tutorial.
2.1. Dependencies
Here is the full pom.xml. It uses the Spring Boot 4.1.1 parent and imports the Spring AI BOM (bill of materials), which sets the version of every Spring AI module:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.howtodoinjava</groupId>
<artifactId>orderdesk-anthropic</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-anthropic</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
The spring-ai-starter-model-anthropic starter adds the AnthropicChatModel bean. The spring-boot-starter-webmvc dependency is the Spring Boot 4 name of the web starter.
2.2. The API Key
Create a key in the Anthropic Console. Your account may also need credits before the API accepts requests. The key is a secret, so keep it out of your code and your Git repository. Put it in an environment variable instead:
export SPRING_AI_ANTHROPIC_API_KEY=sk-ant-your-key
On Windows PowerShell, use $env:SPRING_AI_ANTHROPIC_API_KEY=”sk-ant-your-key”. Set it in the same terminal where you start the app, or in your IDE’s run configuration. Spring Boot reads this variable as the spring.ai.anthropic.api-key property. According to the Spring AI Anthropic page, the ANTHROPIC_API_KEY variable also works.
The app starts even without a key. The first call to Claude then fails:
com.anthropic.errors.UnauthorizedException: 401: {"type":"error","error":{"type":"authentication_error","message":"x-api-key header is required"},...}
The com.anthropic package is Anthropic’s Java SDK, which Spring AI 2.0 uses inside AnthropicChatModel. With a wrong key, the message is “invalid x-api-key”.
2.3. Properties
Here is the full application.properties:
spring.application.name=orderdesk-anthropic
server.port=8083
# Two chat models in one app, so we build the ChatClients ourselves
spring.ai.chat.client.enabled=false
# The local model, as in the other OrderDesk articles
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.model=qwen2.5:3b
spring.ai.ollama.chat.temperature=0.2
# The key comes from the environment variable SPRING_AI_ANTHROPIC_API_KEY
spring.ai.anthropic.chat.model=claude-haiku-4-5
spring.ai.anthropic.chat.max-tokens=500
spring.ai.anthropic.chat.temperature=0.2
The port 8083 keeps this app apart from the other OrderDesk examples. The Anthropic lines pick the model and limit each answer to 500 tokens. The temperature controls how random the answer is; a low value gives more predictable answers.
We use claude-haiku-4-5, Anthropic’s small, fast model. A larger model, such as claude-sonnet-4-6, gives better answers but costs more. See Anthropic’s models overview for names and prices.
Now let’s look at spring.ai.chat.client.enabled=false. Normally, Spring AI auto-configures one ChatClient.Builder, and that builder expects a single ChatModel. Our app has two, so we switch the auto-configured builder off and build our two clients ourselves. That way, no class gets the auto-configured builder by mistake. The Spring AI ChatClient reference covers other ways to work with several chat models.
3. Two ChatClients in One App
ChatConfig creates one client per model. Each client gets the same system prompt. A system prompt is an instruction sent with every request, and defaultSystem(…) sets it once for the client:
package com.howtodoinjava.orderdesk.anthropic;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ChatConfig {
static final String SUPPORT = "You are OrderDesk, the support assistant of an online store. "
+ "Answer in at most three sentences.";
// The hosted model: for questions that need a stronger model
@Bean
ChatClient hostedClient(AnthropicChatModel chatModel) {
return ChatClient.builder(chatModel).defaultSystem(SUPPORT).build();
}
// The local model in Ollama: free, for simple questions
@Bean
ChatClient localClient(OllamaChatModel chatModel) {
return ChatClient.builder(chatModel).defaultSystem(SUPPORT).build();
}
}
We ask for AnthropicChatModel and OllamaChatModel by class. If we asked for ChatModel, Spring would find two beans and fail. ChatClient.builder(chatModel) then wraps each model in its own client.
The controller picks a client from a request parameter. It returns the answer, the model name, and the token counts, which change between providers:
package com.howtodoinjava.orderdesk.anthropic;
// The answer plus the facts that differ between models
public record Reply(String model, String answer, Integer inputTokens, Integer outputTokens) {
}
package com.howtodoinjava.orderdesk.anthropic;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SupportController {
private final ChatClient hostedClient;
private final ChatClient localClient;
SupportController(@Qualifier("hostedClient") ChatClient hostedClient,
@Qualifier("localClient") ChatClient localClient) {
this.hostedClient = hostedClient;
this.localClient = localClient;
}
// model=hosted (default) or model=local
@GetMapping("/ask")
Reply ask(@RequestParam String question,
@RequestParam(defaultValue = "hosted") String model) {
ChatClient client = "local".equals(model) ? localClient : hostedClient;
ChatResponse response = client.prompt().user(question).call().chatResponse();
ChatResponseMetadata metadata = response.getMetadata();
return new Reply(metadata.getModel(), response.getResult().getOutput().getText(),
metadata.getUsage().getPromptTokens(), metadata.getUsage().getCompletionTokens());
}
}
@Qualifier tells Spring which bean to inject when multiple beans match the required type. In this example, it identifies the bean by the name of the @Bean method. Any model value other than local selects Claude.
The chatResponse() method returns the full response rather than just the generated text. This lets us access the response metadata, including the model name and token usage through Usage.
4. Asking Both Models
Start the app with mvn spring-boot:run, then ask the local model:
curl -G localhost:8083/ask --data-urlencode "question=Can I return a kettle after 5 days?" \
--data-urlencode model=local
{"model":"qwen2.5:3b","answer":"Yes, you can return a kettle within 5 days, but it's best to check your store's specific return policy as some may have different terms.","inputTokens":43,"outputTokens":32}
The same request without model=local goes to Claude. With a valid key, you get the same Reply JSON with Claude’s model name and token counts. We didn’t run that call. Here is the request Spring AI sends to Anthropic’s /v1/messages endpoint (we captured it with a small fake server):
{"max_tokens":500,"messages":[{"content":"Can I return a kettle after 5 days?","role":"user"}],"model":"claude-haiku-4-5","system":"You are OrderDesk, the support assistant of an online store. Answer in at most three sentences.","temperature":0.2}
Anthropic’s Messages API puts the system prompt in its own system field, not in the messages. Spring AI does this for us.
The local model doesn’t know OrderDesk’s real return window, which is 10 days. No model knows a store’s rules unless we give them to it, for example with RAG or tools.
5. Extended Thinking: a Claude Feature
Some questions need a few steps of reasoning, such as “the customer wants a refund and a discount; what should we offer?”. Claude can think before it answers, which Anthropic calls extended thinking. The model writes its reasoning first, then the answer. We set the maximum number of tokens it can use for thinking.
The response then holds several content blocks. Thinking blocks hold the reasoning, and a text block holds the answer. Each thinking block also carries a cryptographic signature.
We turn thinking on for one request with AnthropicChatOptions:
package com.howtodoinjava.orderdesk.anthropic;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ThinkingController {
private final ChatClient hostedClient;
ThinkingController(@Qualifier("hostedClient") ChatClient hostedClient) {
this.hostedClient = hostedClient;
}
// Extended thinking: Claude may use up to 1,024 tokens to reason before it answers
@GetMapping("/ask/think")
Thought think(@RequestParam String question) {
ChatResponse response = hostedClient.prompt()
.user(question)
.options(AnthropicChatOptions.builder()
.thinkingEnabled(1024)
.maxTokens(2048)
.temperature(1.0))
.call()
.chatResponse();
// Each thinking block comes back as its own generation, marked with a "signature"
StringBuilder thinking = new StringBuilder();
StringBuilder answer = new StringBuilder();
for (Generation generation : response.getResults()) {
AssistantMessage message = generation.getOutput();
if (message.getMetadata().containsKey("signature")) {
thinking.append(message.getText());
} else if (message.getText() != null) {
answer.append(message.getText());
}
}
return new Thought(thinking.toString(), answer.toString());
}
record Thought(String thinking, String answer) {
}
}
In Spring AI 2.0, options(…) takes the options builder, so there is no .build() call. The Spring AI upgrade notes describe this change.
These three options must follow the rules on the Spring AI Anthropic page:
- thinkingEnabled(1024) sets the thinking budget. It must be at least 1,024 tokens.
- maxTokens(2048) must be larger than the budget, because it covers the thinking and the answer.
- temperature(1.0) is required while thinking is on. Our default of 0.2 would be rejected.
Spring AI turns each content block into its own Generation. According to the response structure section, a thinking block has its text in getText() and its signature in the signature metadata key. Our loop uses that key only as a marker to separate thinking from the answer.
Don’t call .content() here because it reads only the first generation, which can be a thinking block.
We didn’t run this endpoint against Claude. The loop follows the Spring AI documentation. To try it with a key, call:
curl -G localhost:8083/ask/think \
--data-urlencode "question=A customer got the wrong colour and wants a refund and a discount. What should we offer?"
Our fake server shows that Spring AI sends the options as expected, but it doesn’t show what Anthropic accepts:
{"max_tokens":2048,"messages":[{"content":"A customer got the wrong colour and wants a refund and a discount. What should we offer?","role":"user"}],"model":"claude-haiku-4-5","system":"You are OrderDesk, the support assistant of an online store. Answer in at most three sentences.","temperature":1.0,"thinking":{"budget_tokens":1024,"type":"enabled"}}
We return the thinking so you can see it. In a real app, you would usually log it and show customers only the answer. Thinking also adds output tokens, so use it only for the requests that need it.
6. Before You Go to Production
A few things change when real customers start using the hosted model:
- Cost grows with tokens. Record the token counts from Usage, as our Reply returns them, and watch them. Keep max-tokens low enough to cap the cost of one answer.
- A hosted model can fail in ways a local model can’t: a wrong key (401, as above), rate limits when you send too many requests per minute, or a provider outage. Decide what the user sees then. One option is to fall back to the local client.
- Customer messages leave your machine. Check that this is allowed for your data, and don’t send what you don’t need.
- Keep the model name in application.properties, so moving to a newer Claude model is a configuration change.
Because the provider hides behind ChatModel, the rest of OrderDesk works with Claude without code changes.
All articles in order: Spring AI Tutorial
Drop me your questions related to Spring AI with Anthropic Claude in the comments.
Happy Learning !!