Self-correction pass caught this before publishing: the previous commit's test
comment and companion post draft claimed a keepAlive set through
ChatClient.prompt().options(...) never reaches the request Ollama receives,
and worked around it by calling ChatModel.call(Prompt) directly instead. That
claim was never actually verified against /api/ps for the ChatClient path --
only inferred from a failed timing assertion that, it turned out, would have
failed the same way even with a genuinely confirmed unload (see below).
Checked directly: unloading via ChatClient.prompt().options(OllamaChatOptions
.builder()...keepAlive("0")).call() and immediately querying /api/ps shows an
empty model registry, same as the ChatModel path. The options merge works
correctly. Simplified the test back to ChatClient throughout, consistent with
the rest of this series, and removed the incorrect comment.
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FtpJvZfg4nvLvtzgJTDWpB
171 lines
6.9 KiB
Java
171 lines
6.9 KiB
Java
package com.ankurm.ollamalocal;
|
|
|
|
import java.net.URI;
|
|
import java.net.http.HttpClient;
|
|
import java.net.http.HttpRequest;
|
|
import java.net.http.HttpResponse;
|
|
import java.time.Duration;
|
|
|
|
import com.ankurm.ollamalocal.support.Transcript;
|
|
import org.junit.jupiter.api.MethodOrderer;
|
|
import org.junit.jupiter.api.Order;
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.TestMethodOrder;
|
|
import org.testcontainers.junit.jupiter.Container;
|
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
|
import org.testcontainers.ollama.OllamaContainer;
|
|
import org.testcontainers.utility.DockerImageName;
|
|
|
|
import org.springframework.ai.chat.client.ChatClient;
|
|
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
|
|
import org.springframework.ai.embedding.EmbeddingModel;
|
|
import org.springframework.ai.ollama.api.OllamaChatOptions;
|
|
import org.springframework.beans.factory.annotation.Autowired;
|
|
import org.springframework.boot.test.context.SpringBootTest;
|
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
|
import org.springframework.test.context.DynamicPropertySource;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
/**
|
|
* One real, local Ollama server for the whole class, started from a baked image that already
|
|
* has {@code qwen2.5:0.5b} (chat) and {@code all-minilm} (embeddings) pulled -- see
|
|
* {@code scripts/bake-image.sh}. No network call happens during these tests: the container
|
|
* starts from a local image and both models are already on disk inside it.
|
|
*
|
|
* <p>Every other module in this series drives {@code ChatClient} against a hand-written
|
|
* {@code ScriptedChatModel} so the tests are deterministic and need no live model. This module
|
|
* is the deliberate exception: the whole point of "run it locally" is a real model answering a
|
|
* real prompt, so these tests assert only what is genuinely deterministic about a live small
|
|
* model (a non-blank response, a confirmed-empty model registry right after an explicit unload)
|
|
* and leave exact wording, and exact timings, alone.
|
|
*/
|
|
@Testcontainers
|
|
@SpringBootTest
|
|
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
|
class LocalChatAndEmbeddingTest {
|
|
|
|
@Container
|
|
static final OllamaContainer OLLAMA = new OllamaContainer(
|
|
DockerImageName.parse("ollama-baked-qwen05b-minilm:local").asCompatibleSubstituteFor("ollama/ollama"));
|
|
|
|
@DynamicPropertySource
|
|
static void ollamaProperties(DynamicPropertyRegistry registry) {
|
|
registry.add("spring.ai.ollama.base-url", OLLAMA::getEndpoint);
|
|
}
|
|
|
|
private static final HttpClient HTTP = HttpClient.newHttpClient();
|
|
|
|
@Autowired
|
|
private ChatClient chatClient;
|
|
|
|
@Autowired
|
|
private EmbeddingModel embeddingModel;
|
|
|
|
@Test
|
|
@Order(1)
|
|
void reloadAfterAConfirmedUnload() throws Exception {
|
|
// keepAlive("0") tells Ollama to unload the model from memory as soon as this call
|
|
// finishes -- the same knob you'd reach for in production to free RAM/VRAM between
|
|
// bursts of traffic. Passed through ChatClient's per-call options() exactly like any
|
|
// other OllamaChatOptions field.
|
|
this.chatClient.prompt()
|
|
.user("OK")
|
|
.options(OllamaChatOptions.builder().model("qwen2.5:0.5b").keepAlive("0"))
|
|
.call()
|
|
.content();
|
|
|
|
// Don't take the unload on faith -- ask Ollama's own /api/ps, which lists every
|
|
// currently loaded model, and confirm the registry is really empty before measuring
|
|
// what a reload costs.
|
|
String modelsAfterUnload = getModelRegistry();
|
|
assertThat(modelsAfterUnload).contains("\"models\":[]");
|
|
|
|
ChatClient.CallResponseSpec response = this.chatClient.prompt()
|
|
.user("Reply with a single short sentence: why do developers like small local models?")
|
|
.call();
|
|
|
|
String content = response.content();
|
|
ChatResponseMetadata metadata = response.chatResponse().getMetadata();
|
|
|
|
assertThat(content).isNotBlank();
|
|
// Loading is real work -- reading weights off disk and initializing the runtime -- so
|
|
// even a fast reload can't be negative or missing.
|
|
Duration loadDuration = metadata.get("load-duration");
|
|
assertThat(loadDuration).isNotNull();
|
|
assertThat(loadDuration.isNegative()).isFalse();
|
|
|
|
Transcript.write("01-chat-reload-after-unload",
|
|
"/api/ps immediately after the unload call: " + modelsAfterUnload + "\n\n"
|
|
+ "prompt: \"Reply with a single short sentence: why do developers like small local models?\"\n"
|
|
+ "response: " + content + "\n\n" + formatOllamaMetadata(metadata));
|
|
}
|
|
|
|
@Test
|
|
@Order(2)
|
|
void backToBackCallReusesTheAlreadyLoadedModel() {
|
|
ChatClient.CallResponseSpec response = this.chatClient.prompt()
|
|
.user("Reply with a single short sentence: what is Testcontainers for?")
|
|
.call();
|
|
|
|
String content = response.content();
|
|
ChatResponseMetadata metadata = response.chatResponse().getMetadata();
|
|
|
|
assertThat(content).isNotBlank();
|
|
|
|
Transcript.write("02-chat-back-to-back-call",
|
|
"prompt: \"Reply with a single short sentence: what is Testcontainers for?\"\n"
|
|
+ "response: " + content + "\n\n" + formatOllamaMetadata(metadata));
|
|
}
|
|
|
|
@Test
|
|
@Order(3)
|
|
void embeddingsAreDeterministicallySized() {
|
|
float[] vector = this.embeddingModel.embed("Spring AI can run entirely against a local Ollama server.");
|
|
|
|
assertThat(vector).hasSize(384);
|
|
assertThat(this.embeddingModel.dimensions()).isEqualTo(384);
|
|
|
|
StringBuilder prefix = new StringBuilder();
|
|
for (int i = 0; i < 8; i++) {
|
|
if (i > 0) {
|
|
prefix.append(", ");
|
|
}
|
|
prefix.append(String.format("%.5f", vector[i]));
|
|
}
|
|
|
|
Transcript.write("03-embedding-dimensions",
|
|
"model: all-minilm\n" + "dimensions(): " + this.embeddingModel.dimensions() + "\n"
|
|
+ "vector.length: " + vector.length + "\n" + "first 8 values: [" + prefix + ", ...]");
|
|
}
|
|
|
|
private static String getModelRegistry() throws Exception {
|
|
HttpRequest request = HttpRequest.newBuilder(URI.create(OLLAMA.getEndpoint() + "/api/ps")).GET().build();
|
|
return HTTP.send(request, HttpResponse.BodyHandlers.ofString()).body();
|
|
}
|
|
|
|
private static String formatOllamaMetadata(ChatResponseMetadata metadata) {
|
|
Duration total = metadata.get("total-duration");
|
|
Duration load = metadata.get("load-duration");
|
|
Duration promptEval = metadata.get("prompt-eval-duration");
|
|
Duration eval = metadata.get("eval-duration");
|
|
Integer promptEvalCount = metadata.get("prompt-eval-count");
|
|
Integer evalCount = metadata.get("eval-count");
|
|
|
|
StringBuilder sb = new StringBuilder();
|
|
sb.append("total-duration: ").append(total.toMillis()).append("ms\n");
|
|
sb.append("load-duration: ").append(load.toMillis()).append("ms\n");
|
|
sb.append("prompt-eval-count: ").append(promptEvalCount);
|
|
sb.append(", prompt-eval-duration: ").append(promptEval.toMillis()).append("ms\n");
|
|
sb.append("eval-count: ").append(evalCount);
|
|
sb.append(", eval-duration: ").append(eval.toMillis()).append("ms");
|
|
|
|
if (evalCount != null && eval != null && eval.toNanos() > 0) {
|
|
double tokensPerSecond = evalCount / (eval.toNanos() / 1_000_000_000.0);
|
|
sb.append(String.format("%n%.2f tokens/sec (eval-count / eval-duration)", tokensPerSecond));
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
}
|