Add getting-started module: ChatClient fluent API, system/user prompts, streaming, and provider switching via spring.ai.model.chat

This commit is contained in:
2026-09-23 04:20:52 +00:00
committed by Claude
parent 1d4625a1c2
commit c1fc41e0de
22 changed files with 697 additions and 0 deletions
@@ -0,0 +1,12 @@
package com.ankurm.gettingstarted;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class GettingStartedApplication {
public static void main(String[] args) {
SpringApplication.run(GettingStartedApplication.class, args);
}
}
@@ -0,0 +1,24 @@
package com.ankurm.gettingstarted.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* The one bean this module exists to explain. {@link ChatClient.Builder} arrives
* autoconfigured and already pointed at whichever {@code ChatModel} bean
* {@code spring.ai.model.chat} selected — this class never mentions OpenAI or Ollama
* by name, and that is the whole point of the properties-based switch.
*/
@Configuration
public class ChatClientConfig {
@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();
}
}
@@ -0,0 +1,51 @@
package com.ankurm.gettingstarted.web;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
/**
* Three endpoints, three corners of the {@link ChatClient} fluent API:
* a plain call, a call with a templated system prompt, and a streamed call.
* See {@code docs} on ankurm.com for the walkthrough — the endpoints
* themselves carry no comment beyond what the method name says, deliberately,
* since the interesting part is the one-line body.
*/
@RestController
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient chatClient) {
this.chatClient = chatClient;
}
@GetMapping("/api/chat")
public String chat(@RequestParam String message) {
return this.chatClient.prompt()
.user(message)
.call()
.content();
}
@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();
}
@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();
}
}
@@ -0,0 +1,18 @@
spring:
application:
name: getting-started
ai:
# Which provider's ChatModel bean gets built. Flip this one line, keep the code
# in ChatClientConfig and ChatController unchanged, and add the matching provider
# properties below.
model:
chat: ${CHAT_PROVIDER:openai}
openai:
api-key: ${OPENAI_API_KEY:}
chat:
model: gpt-4o
temperature: 0.1
ollama:
base-url: ${OLLAMA_BASE_URL:http://localhost:11434}
chat:
model: llama3.2
@@ -0,0 +1,121 @@
package com.ankurm.gettingstarted;
import java.time.Duration;
import java.util.List;
import com.ankurm.gettingstarted.config.TestChatModelConfig;
import com.ankurm.gettingstarted.support.FakeChatModel;
import com.ankurm.gettingstarted.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = "spring.ai.model.chat=none")
@org.springframework.context.annotation.Import(TestChatModelConfig.class)
class ChatClientEndpointsTest {
@LocalServerPort
int port;
@Autowired
FakeChatModel fakeChatModel;
private WebTestClient client() {
return WebTestClient.bindToServer()
.baseUrl("http://localhost:" + port)
.responseTimeout(Duration.ofSeconds(10))
.build();
}
@Test
void plainCallReturnsChatClientContent() {
try (Transcript t = new Transcript("01-plain-call.txt",
"GET /api/chat -- ChatClient.prompt().user(...).call().content()")) {
String body = client().get()
.uri("/api/chat?message=" + "What package is ChatClient in?")
.exchange()
.expectStatus().isOk()
.expectBody(String.class)
.returnResult()
.getResponseBody();
t.line("$ curl 'http://localhost:%d/api/chat?message=What+package+is+ChatClient+in?'", port)
.blank()
.line(body);
assertThat(body).isEqualTo(
"You said: \"What package is ChatClient in?\". That is 30 characters.");
}
}
@Test
void systemPromptTemplateIsFilledBeforeItReachesTheModel() {
try (Transcript t = new Transcript("02-system-template.txt",
"GET /api/chat/as -- a templated system prompt, filled via .param(\"voice\", ...)")) {
String body = client().get()
.uri("/api/chat/as?voice=pirate&message=Where+is+my+jar+cached")
.exchange()
.expectStatus().isOk()
.expectBody(String.class)
.returnResult()
.getResponseBody();
List<String> sent = fakeChatModel.promptTexts();
String lastPrompt = sent.get(sent.size() - 1);
t.line("$ curl 'http://localhost:%d/api/chat/as?voice=pirate&message=Where+is+my+jar+cached'", port)
.blank()
.line("Response body:")
.line(body)
.blank()
.line("Exact text FakeChatModel received (proves the {voice} placeholder was substituted"
+ " by ChatClient before the ChatModel ever saw the prompt):")
.line(lastPrompt);
assertThat(lastPrompt)
.contains("in the voice of a pirate")
.doesNotContain("{voice}");
assertThat(body).startsWith("As a pirate:");
}
}
@Test
void streamEndpointReturnsAFluxOfContentChunks() {
try (Transcript t = new Transcript("03-streaming.txt",
"GET /api/chat/stream -- ChatClient.prompt().user(...).stream().content()")) {
List<String> chunks = client().get()
.uri("/api/chat/stream?message=Stream+this")
.accept(org.springframework.http.MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.returnResult(String.class)
.getResponseBody()
.collectList()
.block(Duration.ofSeconds(10));
t.line("$ curl -N 'http://localhost:%d/api/chat/stream?message=Stream+this'", port)
.blank()
.line("Chunks received: %d", chunks.size())
.line("Joined: %s", String.join("", chunks));
// FakeChatModel does not override stream(): ChatModel's own default implementation
// wraps call() in a single-element Flux, so a stub model streams in exactly one
// chunk. A real provider chunks token by token -- see the "going deeper" link below
// for where that default is defined.
assertThat(chunks).hasSize(1);
assertThat(chunks.get(0)).isEqualTo(
"You said: \"Stream this\". That is 11 characters.");
}
}
}
@@ -0,0 +1,64 @@
package com.ankurm.gettingstarted;
import com.ankurm.gettingstarted.support.Transcript;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.model.ollama.autoconfigure.OllamaApiAutoConfiguration;
import org.springframework.ai.model.ollama.autoconfigure.OllamaChatAutoConfiguration;
import org.springframework.ai.model.openai.autoconfigure.OpenAiChatAutoConfiguration;
import org.springframework.ai.model.tool.autoconfigure.ToolCallingAutoConfiguration;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Proves the claim the post makes in prose: flipping {@code spring.ai.model.chat} is enough to
* change which {@code ChatModel} implementation Spring wires into {@code ChatClient.Builder},
* with zero code change in {@code ChatClientConfig} or {@code ChatController}. Neither provider's
* server is contacted -- this only inspects which bean class the context assembled.
*
* <p>{@code ToolCallingAutoConfiguration} has to be included too: {@code OpenAiChatAutoConfiguration}
* autowires a {@code ToolCallingManager} constructor argument, and {@code ApplicationContextRunner}
* only activates the autoconfigurations it is explicitly given, unlike a real Boot application
* where every {@code AutoConfiguration.imports} entry on the classpath is a candidate. The first
* run of this test failed context startup with {@code NoSuchBeanDefinitionException} for exactly
* that type until this was added. {@code OllamaChatAutoConfiguration} needed the same fix for a
* different reason: it depends on an {@code OllamaApi} bean, which its sibling
* {@code OllamaApiAutoConfiguration} provides -- a real Boot app pulls both in automatically from
* the same starter jar, but this runner has to be told about each one explicitly.
*/
class ProviderSwitchTest {
private final ApplicationContextRunner runner = new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(
ToolCallingAutoConfiguration.class,
OpenAiChatAutoConfiguration.class,
OllamaApiAutoConfiguration.class,
OllamaChatAutoConfiguration.class));
@Test
void openaiPropertySelectsTheOpenAiChatModel() {
try (Transcript t = new Transcript("04-provider-switch.txt",
"Which ChatModel class spring.ai.model.chat selects")) {
runner.withPropertyValues(
"spring.ai.model.chat=openai",
"spring.ai.openai.api-key=sk-test-placeholder-never-sent")
.run(context -> {
assertThat(context).hasNotFailed();
ChatModel model = context.getBean(ChatModel.class);
t.line("spring.ai.model.chat=openai -> %s", model.getClass().getName());
assertThat(model.getClass().getSimpleName()).isEqualTo("OpenAiChatModel");
});
runner.withPropertyValues("spring.ai.model.chat=ollama")
.run(context -> {
ChatModel model = context.getBean(ChatModel.class);
t.line("spring.ai.model.chat=ollama -> %s", model.getClass().getName());
assertThat(model.getClass().getSimpleName()).isEqualTo("OllamaChatModel");
});
}
}
}
@@ -0,0 +1,27 @@
package com.ankurm.gettingstarted.config;
import com.ankurm.gettingstarted.support.FakeChatModel;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
/**
* Active only in tests that import it. Paired with {@code spring.ai.model.chat=none} on the test
* itself, which stops the real OpenAI/Ollama autoconfiguration from building a {@code ChatModel}
* at all, so this bean is the only one in the context.
*/
@TestConfiguration
public class TestChatModelConfig {
@Bean
@Primary
FakeChatModel fakeChatModel() {
return new FakeChatModel();
}
@Bean
ChatModel chatModel(FakeChatModel fake) {
return fake;
}
}
@@ -0,0 +1,93 @@
package com.ankurm.gettingstarted.support;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
/**
* A scripted {@link ChatModel}. It never leaves the JVM, so every test that wires it in is
* a real Spring context wiring a real {@code ChatClient} around a stand-in for the network call
* &mdash; what the tests in this module prove is that ChatClient assembled the prompt correctly,
* never what a real OpenAI or Ollama reply would say.
*
* <p>The default-system prompt from {@code ChatClientConfig} and the voice-template prompt from
* {@code ChatController#chatAs} are both text this module writes itself, so recognising them by a
* fixed substring is matching against our own code, not guessing at a model's behaviour.
*/
public class FakeChatModel implements ChatModel {
private static final String DEFAULT_SYSTEM_MARKER = "terse Java and Spring assistant";
private static final String DEFAULT_SYSTEM_SUFFIX = "unless the user asks for code.";
private static final String VOICE_MARKER = "in the voice of a ";
private static final String VOICE_SYSTEM_SUFFIX = "still in two sentences.";
private final List<Prompt> prompts = new CopyOnWriteArrayList<>();
public List<Prompt> prompts() {
return Collections.unmodifiableList(prompts);
}
@Override
public ChatResponse call(Prompt prompt) {
prompts.add(prompt);
String contents = prompt.getContents();
return new ChatResponse(List.of(new Generation(new AssistantMessage(reply(contents)))));
}
/**
* {@code ChatModel} does not implement {@code stream()} by delegating to {@code call()} --
* its default throws {@code UnsupportedOperationException("streaming is not supported")}
* (verified from the {@code ChatModel.java:65} stack frame the first run of this test
* produced). A real provider streams the reply token by token; this stand-in streams it as
* one chunk, which is honest about what it is standing in for.
*/
@Override
public Flux<ChatResponse> stream(Prompt prompt) {
return Flux.just(call(prompt));
}
private static String reply(String contents) {
int voiceIdx = contents.indexOf(VOICE_MARKER);
if (voiceIdx >= 0) {
int start = voiceIdx + VOICE_MARKER.length();
int end = contents.indexOf(',', start);
String voice = contents.substring(start, end < 0 ? contents.length() : end).strip();
String user = afterSuffix(contents, VOICE_SYSTEM_SUFFIX);
return "As a " + voice + ": " + user + " -- yes, and it is exactly " + user.length()
+ " characters long.";
}
if (contents.contains(DEFAULT_SYSTEM_MARKER)) {
String user = afterSuffix(contents, DEFAULT_SYSTEM_SUFFIX);
return "You said: \"" + user + "\". That is " + user.length() + " characters.";
}
return "FakeChatModel saw a prompt it does not recognise: " + contents;
}
/**
* {@code Prompt#getContents()} joins every message's text with no separator at all -- not a
* space, not a newline (verified: the first run of this test showed
* {@code "...code.What package..."} glued together with no boundary). So the user text is
* simply whatever follows this module's own fixed system-prompt suffix.
*/
private static String afterSuffix(String contents, String suffix) {
int idx = contents.indexOf(suffix);
return (idx >= 0 ? contents.substring(idx + suffix.length()) : contents).strip();
}
/** Convenience for tests: every prompt's full joined text, for assertions on what was sent. */
public List<String> promptTexts() {
List<String> out = new ArrayList<>();
for (Prompt p : prompts) {
out.add(p.getContents());
}
return out;
}
}
@@ -0,0 +1,47 @@
package com.ankurm.gettingstarted.support;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* Writes a numbered transcript under {@code output/} (repository root, not {@code docs/}) and
* echoes it to the console. Every console block quoted in the article comes out of one of these
* files verbatim.
*/
public final class Transcript implements AutoCloseable {
private final Path path;
private final StringWriter buffer = new StringWriter();
private final PrintWriter out = new PrintWriter(buffer);
public Transcript(String fileName, String title) {
this.path = Path.of("output", fileName);
out.println("# " + title);
out.println();
}
public Transcript line(String format, Object... args) {
out.println(args.length == 0 ? format : String.format(format, args));
return this;
}
public Transcript blank() {
out.println();
return this;
}
@Override
public void close() {
out.flush();
try {
Files.createDirectories(path.getParent());
Files.writeString(path, buffer.toString());
} catch (IOException e) {
throw new IllegalStateException("could not write " + path, e);
}
System.out.print(buffer);
}
}