Using LLM4S with Micronaut
Call a model from a Micronaut 4 application: a @Factory bean for the JLlmClient, its settings in application.yml, a blocking and a streamed endpoint, a health indicator, and tests with FakeLlmClient.
Table of contents
- Run the sample
- Dependencies
- Configuration
- The client as a bean
- Calling it from a controller
- Health check
- Testing
- Pitfalls
- Where next
There is no Micronaut module for llm4s. This page wires the Java API into Micronaut with a factory and
@ConfigurationProperties, which is all a first application needs. Every Java, YAML and Gradle snippet is an excerpt
of the runnable sample,
modules/samples/micronaut-java, which CI
builds and tests against the library on every pull request.
Run the sample
You need JDK 21 or newer and Gradle 8.11 or newer. The sample’s default provider is a local
Ollama server at http://localhost:11434, which needs no key:
1
2
3
ollama pull llama3.2
cd modules/samples/micronaut-java
gradle run -Pllm4sVersion=0.5.1
Then ask it something, once as a whole reply and once streamed:
1
2
3
curl -X POST -H 'Content-Type: text/plain' -d 'What is Micronaut, in one sentence?' http://localhost:8080/chat
curl -N -X POST -H 'Content-Type: text/plain' -d 'What is Micronaut?' http://localhost:8080/chat/stream
curl http://localhost:8080/health
Without -Pllm4sVersion the build uses 0.1.0-SNAPSHOT, the code of your checkout, which you first publish to your
local Maven repository with sbt publishM2 as the sample’s README shows. gradle test needs neither Ollama nor a
key: the tests replace the client with a FakeLlmClient.
Dependencies
llm4s is Scala 3 only, and the _3 suffix is part of every artifact name. llm4s-java-api brings llm4s-core,
llm4s-agent and the Scala library, but no provider: declare the provider modules the factory builds clients for.
Set llm4sVersion to 0.5.1 (or later).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Micronaut's dependency injection is compile-time: its annotation processor writes the bean definitions for this
// project's classes. It never sees inside the llm4s jars, so the client becomes a bean through a @Factory.
annotationProcessor(platform("io.micronaut.platform:micronaut-platform:$micronautVersion"))
annotationProcessor("io.micronaut:micronaut-inject-java")
annotationProcessor("io.micronaut.serde:micronaut-serde-processor")
implementation(platform("io.micronaut.platform:micronaut-platform:$micronautVersion"))
implementation("io.micronaut:micronaut-http-server-netty")
implementation("io.micronaut:micronaut-management")
implementation("io.micronaut.serde:micronaut-serde-jackson")
runtimeOnly("org.yaml:snakeyaml")
runtimeOnly("ch.qos.logback:logback-classic")
// Scala 3 only, and the _3 suffix is part of the name. The POM brings llm4s-core, llm4s-agent and the Scala
// library; llm4s-java-api brings no provider, so declare the ones the factory builds clients for.
implementation("org.llm4s:llm4s-java-api_3:$llm4sVersion")
implementation("org.llm4s:llm4s-ollama_3:$llm4sVersion")
implementation("org.llm4s:llm4s-openai-compatible_3:$llm4sVersion")
The sample builds with Micronaut 4.10. Maven works the same way: the Micronaut parent POM, and the three llm4s artifacts above as dependencies.
Configuration
The client’s settings are an llm4s block of application.yml:
1
2
3
4
5
llm4s:
provider: ollama
model: llama3.2
# base-url: http://localhost:11434
# api-key:
Micronaut binds the block to a record at startup. Each key can also come from a system property
(-Dllm4s.model=...) or an environment variable (LLM4S_MODEL, LLM4S_BASE_URL, LLM4S_API_KEY), so a key never
has to be in the file:
1
2
3
4
5
6
7
@ConfigurationProperties("llm4s")
public record Llm4sSettings(
@Bindable(defaultValue = "ollama") String provider,
String model,
@Nullable String baseUrl,
@Nullable String apiKey) {
}
These are the sample’s own keys, mapped onto the Java API’s builders below; llm4s itself
does not read application.yml. The sample builds ollama and openai-compatible clients, the second reaching any
/chat/completions endpoint, OpenAI’s included, by its base URL.
The client as a bean
Micronaut computes its beans at compile time from the classes it compiles. The llm4s classes are already compiled,
so Micronaut cannot make a bean of JLlmClient by itself: a @Factory builds it. @Bean(preDestroy = "close")
closes the client, and the connections it holds, when the application context stops.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Factory
public class LlmClientFactory {
@Singleton
@Bean(preDestroy = "close")
JLlmClient llmClient(Llm4sSettings settings) {
LlmResult<JLlmClient> client = build(settings);
if (client.isFailure()) {
// a missing base URL or an unknown provider: fail the first injection with llm4s's message
throw new IllegalStateException(
"Cannot build the llm4s client: " + client.getError().getMessage(), client.getError());
}
return client.get();
}
static LlmResult<JLlmClient> build(Llm4sSettings settings) {
return switch (settings.provider()) {
case "ollama" -> {
var builder = Llm4s.ollama().model(settings.model());
yield (settings.baseUrl() == null ? builder : builder.baseUrl(settings.baseUrl())).build();
}
case "openai-compatible" -> {
var builder = Llm4s.openAiCompatible().model(settings.model()).baseUrl(settings.baseUrl());
yield (settings.apiKey() == null ? builder : builder.apiKey(settings.apiKey())).build();
}
default -> throw new IllegalStateException(
"Unknown llm4s.provider '" + settings.provider() + "': use ollama or openai-compatible");
};
}
}
One client serves the whole application: it is thread-safe, and building one per request would open a connection pool per request.
Calling it from a controller
JLlmClient.complete blocks until the model answers, which can take seconds. A Micronaut controller runs on a Netty
event-loop thread unless told otherwise, and a blocking call there stalls every request that thread serves.
@ExecuteOn(TaskExecutors.BLOCKING) moves the call to the blocking executor, which on JDK 21 gives each request a
virtual thread (Virtual threads).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Post(consumes = MediaType.TEXT_PLAIN, produces = MediaType.TEXT_PLAIN)
@ExecuteOn(TaskExecutors.BLOCKING)
public HttpResponse<String> chat(@Body String question) {
if (question == null || question.isBlank()) {
return HttpResponse.badRequest("Send a question as the request body");
}
LlmResult<String> answer = client.complete(ConversationBuilder.create()
.system("You are a concise assistant.")
.user(question)
.build());
if (answer.isSuccess()) {
return HttpResponse.ok(answer.get());
}
LlmException error = answer.getError();
return HttpResponse.<String>status(HttpStatus.valueOf(statusOf(error)))
.body(error.getKind() + ": " + error.getMessage());
}
A failed call is an LlmResult value, not an exception, so the controller reads its
kind and answers with a status: a rate limit stays a 429, a timeout becomes a 504, and
anything else a 502.
The streamed endpoint needs no executor. streamAsPublisher returns a JDK Flow.Publisher that runs the call on a
thread of its own; FlowAdapters (from Reactive Streams, which Micronaut already depends on) turns it into the
Publisher Micronaut writes out, and each piece of text becomes a server-sent Event:
1
2
3
4
5
6
7
8
@Post(value = "/stream", consumes = MediaType.TEXT_PLAIN, produces = MediaType.TEXT_EVENT_STREAM)
public Publisher<Event<String>> stream(@Body String question) {
Publisher<String> pieces = FlowAdapters.toPublisher(client.streamAsPublisher(ConversationBuilder.create()
.system("You are a concise assistant.")
.user(question)
.build()));
return Publishers.map(pieces, Event::of);
}
Return Events, not bare Strings: a Publisher<String> is written as raw text with no data: framing, which a
browser’s EventSource cannot read.
Health check
micronaut-management serves GET /health. The sample adds an llm4s entry that is UP once the client is built
and shows the provider and model. It does not call the model, so a probe costs no tokens and does not go DOWN when
the provider is slow:
1
2
3
4
5
@Override
protected Map<String, String> getHealthInformation() {
healthStatus = HealthStatus.UP;
return Map.of("provider", settings.provider(), "model", settings.model());
}
endpoints.health.details-visible: ANONYMOUS in application.yml shows the details to every caller; restrict it
in production.
Testing
FakeLlmClient from llm4s-java-api (Testing your code) is a real JLlmClient that
answers from a script, with no network and no key. JLlmClient is a final class, so @MockBean, which proxies the
bean it replaces, cannot replace it; a test factory with @Replaces does:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// JLlmClient is final, so @MockBean (which proxies) cannot replace it: a factory with @Replaces does.
@Factory
static class FakeClients {
@Singleton
FakeLlmClient fake() {
return FakeLlmClient.builder()
.reply("Micronaut is a JVM framework.")
.failWith(LlmErrorKind.RATE_LIMIT, "slow down")
.reply("Streamed answer")
.build();
}
@Singleton
@Replaces(JLlmClient.class)
JLlmClient client(FakeLlmClient fake) {
return fake.client();
}
}
The @MicronautTest then calls /chat, /chat/stream and /health over HTTP and checks the replies, the 429 of
the scripted failure and the prompt the fake recorded (fake.prompts()).
Pitfalls
- Compile-time injection and the Scala jars. Micronaut’s annotation processor sees only your sources. No llm4s
class is a bean, an introspected type or serializable by Micronaut Serde: build the client in a
@Factory, and map llm4s results to your own records before returning them as JSON. - The event loop. Every blocking
JLlmClientmethod needs@ExecuteOn(TaskExecutors.BLOCKING); the streamed and*Asyncmethods do not. - JDK 21. llm4s is JDK 21 bytecode; compile and run the application on 21 or newer.
- Vendor SDKs.
llm4s-openaiandllm4s-anthropicbring the vendors’ Java SDKs and their Jackson; the Micronaut platform BOM may pin a different Jackson. The sample avoids both withopenai-compatible; if you add them, checkgradle dependenciesfor the Jackson version that wins. - Native image. Not supported: llm4s ships no GraalVM metadata yet (#1463).
Where next
- Java: conversations, completion options, structured output and failures from Java.
- Java threading and cancellation: which calls block and how to cancel them.
- Helidon and Quarkus: the same client in the other JVM frameworks.