Add getting-started module: ChatClient fluent API, system/user prompts, streaming, and provider switching via spring.ai.model.chat
This commit is contained in:
+12
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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
|
||||
* — 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user