Your First LLM4S Program
Build your first LLM-powered application in minutes.
Table of contents
- Hello, LLM!
- Understanding the Code
- Adding Conversation Context
- Adding Tool Calling
- Streaming Responses
- Error Handling Patterns
- Configuration Options
- Complete Working Example
- Common Patterns
- Performance Optimization
- Running the Examples
- Next Steps
- Learning Resources
Hello, LLM!
Let’s start with the simplest possible LLM4S program - a “Hello World” that asks the LLM a question.
Create the File
The examples use llm4s 0.5.1 on Scala 3.7.x and JDK 21. Add llm4s-core and the
provider module you call, here llm4s-openai (see Installation), to build.sbt:
1
2
3
4
5
6
7
val llm4sVersion = "0.5.1"
libraryDependencies ++= Seq(
"org.llm4s" %% "llm4s-core" % llm4sVersion,
"org.llm4s" %% "llm4s-openai" % llm4sVersion,
"org.llm4s" %% "llm4s-agent" % llm4sVersion // only for the agent examples further down
)
Then create HelloLLM.scala.
It reads the provider from a named section in src/main/resources/application.conf:
1
2
3
4
5
6
7
8
9
10
llm4s {
providers {
provider = "openai-main" # the default: the name of a section below
openai-main {
provider = "openai"
model = "gpt-4o-mini"
}
}
}
The Configuration guide has sections for Anthropic, Gemini, Azure and Ollama; the program below does not change when you switch.
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
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.{LLMClient, LLMConnect}
import org.llm4s.llmconnect.model.{ Conversation, UserMessage }
import org.llm4s.model.ModelRegistryService
// 1. Core Logic: Depends only on the injected client, not configuration
class HelloLLM(client: LLMClient) {
def sayHello(): Unit = {
val result = client.complete(Conversation(Seq(UserMessage("What is Scala?"))))
result match {
case Right(completion) =>
println(s"Response: ${completion.content}")
case Left(error) =>
println(s"Error: $error")
}
}
}
// 2. Configuration Boundary: The application entry point
object Main extends App {
val startup = for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
} yield new HelloLLM(client).sayHello()
startup.left.foreach(err => println(s"Startup Error: $err"))
}
Run It
1
2
3
4
# llm4s-openai binds OPENAI_API_KEY to OpenAI's shared key, which the section uses
export OPENAI_API_KEY=sk-...
sbt run
Expected Output
1
2
3
Response: Scala is a high-level programming language that combines
object-oriented and functional programming paradigms. It runs on the
JVM and is known for its strong type system and concurrency support.
Understanding the Code
Let’s break down what’s happening:
1. The Configuration Boundary
1
2
3
4
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
In LLM4S, we follow a strict configuration boundary. The entry point (Main) builds the client:
-
Loads the typed config of the section
llm4s.providers.providernames inapplication.conf -
Selects the appropriate provider (OpenAI, Anthropic, etc.)
-
Injects the
LLMClientinto your core logic (HelloLLM).
2. Complete with Messages
1
2
3
4
val response = client.complete(
Conversation(List(UserMessage("What is Scala?"))),
CompletionOptions()
)
- conversation: The conversation messages (User, Assistant, System), wrapped in
Conversation - options: Per-request completion settings; the model is selected when the client is configured
- Returns
Result[Completion]
3. Result Handling
1
2
3
4
result match {
case Right(completion) => // Success
case Left(error) => // Error
}
LLM4S uses Result types instead of exceptions:
Right(value)= successLeft(error)= failure
This makes error handling explicit and type-safe.
Adding Conversation Context
Let’s make our program more interesting with a multi-turn conversation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model._
class ConversationExample(client: LLMClient) {
def run(): Unit = {
val result = client.complete(
Conversation(Seq(
SystemMessage("You are a helpful programming tutor."),
UserMessage("What is Scala?"),
AssistantMessage("Scala is a high-level programming language..."),
UserMessage("How does it compare to Java?")
))
)
result.fold(
error => println(s"Error: $error"),
completion => println(s"Response: ${completion.content}")
)
}
}
Message Types
LLM4S provides three message types:
| Type | Purpose | Example |
|---|---|---|
SystemMessage |
Set LLM behavior | “You are a helpful assistant” |
UserMessage |
User’s input | “What is Scala?” |
AssistantMessage |
LLM’s previous responses | “Scala is a programming language…” |
Adding Tool Calling
Now let’s give the LLM access to a tool:
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
31
32
33
import org.llm4s.llmconnect.LLMClient
import org.llm4s.toolapi.{ Schema, ToolBuilder, ToolRegistry }
import org.llm4s.agent.Agent
class ToolExample(client: LLMClient) {
// Define a simple tool
def getWeather(location: String): String = {
s"The weather in $location is sunny and 72°F"
}
private val weatherSchema = Schema
.`object`[Map[String, Any]]("Weather query parameters")
.withProperty(Schema.property("location", Schema.string("The city to look up")))
def run(): Unit = {
val result = for {
weatherTool <- ToolBuilder[Map[String, Any], String](
"get_weather",
"Get current weather for a location",
weatherSchema
).withHandler(extractor => extractor.getString("location").map(getWeather)).buildSafe()
agent <- Agent.builder("weather-agent", client)
.withTools(new ToolRegistry(Seq(weatherTool)))
.build()
result <- agent.run("What's the weather in Paris?")
} yield result
result.fold(
error => println(s"Error: $error"),
r => println(s"Final response: ${r.answer.getOrElse(r.status.toString)}")
)
}
}
The agent will:
- Understand you’re asking about weather
- Call the
get_weathertool with “Paris” - Use the tool result to formulate a response
Streaming Responses
For real-time output, use streaming:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model._
class StreamingExample(client: LLMClient) {
def run(): Unit = {
val result = client.streamComplete(
Conversation(Seq(UserMessage("Write a short poem about Scala"))),
onChunk = chunk => chunk.content.foreach(print) // Print each token as it arrives
)
result.fold(
error => println(s"Error: $error"),
_ => println("\nDone!")
)
}
}
Output appears token-by-token in real-time, like ChatGPT!
Error Handling Patterns
Basic Error Handling
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.llm4s.error.{ AuthenticationError, NetworkError, ValidationError }
import org.llm4s.llmconnect.model._
// Assuming client is injected
val result = client.complete(Conversation(Seq(UserMessage("Hello"))))
result match {
case Right(completion) =>
// Success
println(completion.content)
case Left(error) =>
// Handle different error types
error match {
case e: NetworkError => println(s"Network issue: ${e.message}")
case e: AuthenticationError => println(s"Auth failed: ${e.message}")
case e: ValidationError => println(s"Invalid input: ${e.message}")
case _ => println(s"Error: $error")
}
}
Using fold
1
2
3
4
result.fold(
error => println(s"Failed: $error"),
completion => println(s"Success: ${completion.content}")
)
Mapping Results
1
2
val content: Result[String] = result.map(_.content)
val uppercased: Result[String] = content.map(_.toUpperCase)
Configuration Options
Using Different Models
1
2
3
4
5
import org.llm4s.config.Llm4sConfig
// The model belongs to the provider section, so to use another model load the section that names it,
// for example one with provider = "openai" and model = "gpt-4-turbo"
val gpt4Turbo = Llm4sConfig.provider("openai-gpt4-turbo")
Provider-Specific Settings
The model and provider come from the section in application.conf; per-request settings such
as temperature and token limits go in CompletionOptions:
1
2
3
4
openai-main {
provider = "openai"
model = "gpt-4o"
}
1
client.complete(conversation, CompletionOptions(temperature = 0.7, maxTokens = Some(1000)))
Complete Working Example
Here’s a complete example combining everything we’ve learned:
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.{LLMClient, LLMConnect}
import org.llm4s.llmconnect.model._
import org.llm4s.toolapi.{ Schema, ToolBuilder, ToolRegistry }
import org.llm4s.agent.Agent
class ComprehensiveAgent(client: LLMClient) {
def calculate(expression: String): String = {
// Simple calculator (use proper eval in production!)
s"Result: ${expression} = 42"
}
private val calcSchema = Schema
.`object`[Map[String, Any]]("Calculator parameters")
.withProperty(Schema.property("expression", Schema.string("The expression to evaluate")))
def run(): Unit = {
println("🚀 Starting LLM4S Example...")
// Tools belong to the agent, so give them to the builder
val result = for {
calcTool <- ToolBuilder[Map[String, Any], String](
"calculate",
"Evaluate a mathematical expression",
calcSchema
).withHandler(extractor => extractor.getString("expression").map(calculate)).buildSafe()
agent <- Agent.builder("calc-agent", client)
.withTools(new ToolRegistry(Seq(calcTool)))
.build()
result <- agent.run("What is 6 times 7? Please use the calculator.")
} yield result
result match {
case Right(r) =>
println(s"✅ Success!")
println(s"Response: ${r.answer.getOrElse(r.status.toString)}")
println(s"Messages exchanged: ${r.messages.length}")
case Left(error) =>
println(s"❌ Error: $error")
System.exit(1)
}
}
}
// Application Entry Point
object ComprehensiveMain extends App {
val startup = for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given org.llm4s.model.ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
} yield new ComprehensiveAgent(client).run()
startup.left.foreach(err => println(s"Startup Error: $err"))
}
Common Patterns
Pattern 1: Simple Query
1
2
3
4
// Assuming client is injected
val response = client.complete(
Conversation(Seq(UserMessage("Your question here")))
)
Pattern 2: With System Prompt
1
2
3
4
5
6
7
// Assuming client is injected
val response = client.complete(
Conversation(Seq(
SystemMessage("You are an expert in..."),
UserMessage("Question")
))
)
Pattern 3: Agent with Tools
1
2
3
4
5
// Assuming client is injected
val result = for {
agent <- Agent.builder("assistant", client).withTools(new ToolRegistry(myTools)).build()
result <- agent.run("User query")
} yield result
Pattern 4: Streaming
1
2
3
4
5
// Assuming client is injected
val completion = client.streamComplete(
Conversation(Seq(UserMessage("Your question here"))),
onChunk = chunk => chunk.content.foreach(print)
)
Performance Optimization
1. Reuse Client Instances
Don’t create a new client for every request:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Assuming client: LLMClient is injected into your class
// ✅ Good: Reuse existing injected client
(1 to 10).foreach { i =>
client.complete(Conversation(Seq(UserMessage(s"Question $i"))))
}
// ❌ Bad: Creating new client inside the loop (wasteful and violates boundaries)
(1 to 10).foreach { i =>
for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
badClient <- LLMConnect.getClient(providerConfig) // Don't do this!
response <- badClient.complete(
Conversation(Seq(UserMessage(s"Q$i")))
)
} yield response
}
2. Use Streaming for Long Responses
Streaming gets you the first token faster and improves perceived latency:
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.llmconnect.model._
// Assuming client is injected
val streamResult = client.streamComplete(
Conversation(Seq(UserMessage("Write a long essay about Scala"))),
onChunk = chunk => chunk.content.foreach(print) // Prints as tokens arrive
)
streamResult match {
case Right(completion) => println(s"\nCompleted with ${completion.usage.map(_.totalTokens).getOrElse(0)} tokens")
case Left(error) => println(s"Error: $error")
}
When to stream:
- User-facing applications (chat interfaces)
- Long-form content generation
- When you need to show progress
When not to stream:
- Short queries (< 100 tokens)
- When you need the full response before processing
- Background batch jobs
3. Parallelize Independent Requests
Process multiple unrelated queries concurrently:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import scala.concurrent.{Future, ExecutionContext, Await}
import scala.concurrent.duration._
import ExecutionContext.Implicits.global
// Assuming client is injected
val queries = List(
"What is Scala?",
"What is functional programming?",
"What is the JVM?"
)
// Run all queries in parallel
val futures = queries.map { query =>
Future {
client.complete(Conversation(Seq(UserMessage(query))))
}
}
val results = Await.result(Future.sequence(futures), 30.seconds)
results.foreach {
case Right(response) => println(response.content)
case Left(error) => println(s"Error: $error")
}
4. Set Appropriate Timeouts
Each client has a default request and stream timeout (two minutes for a completion, five or ten for a stream), and a provider section can change them:
1
2
3
4
5
llm4s.providers.my-openai {
provider = "openai"
model = "gpt-4o"
timeouts { request = 3m, stream = 15m }
}
See Timeouts for the keys, the defaults per provider and what each timeout bounds. For long-form generation, prefer streaming (above).
5. Use Cheaper Models for Development
Change the section’s model, or point llm4s.providers.provider at another section (see
Switching providers):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
llm4s.providers {
provider = "ollama-local" # development: free, local
ollama-local {
provider = "ollama"
model = "llama3.2"
baseUrl = "http://localhost:11434"
}
openai-main { # production: select with provider = "openai-main"
provider = "openai"
model = "gpt-4o-mini" # much cheaper than gpt-4o
}
}
Only the section being loaded is validated, so OPENAI_API_KEY need not be set while
ollama-local is the default - only where openai-main is selected.
6. Batch Embeddings
When generating embeddings for RAG:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.llm4s.llmconnect.model.EmbeddingRequest
// ✅ Good: Batch processing
val documents: List[String] = loadDocuments() // say, a thousand documents
val batchSize = 100
val allEmbeddings = documents.grouped(batchSize).flatMap { batch =>
embedder.embed(EmbeddingRequest(batch, embeddingModel)) match {
case Right(response) => response.embeddings
case Left(error) =>
println(s"Batch failed: $error")
List.empty
}
}.toList
// ❌ Bad: One at a time (slow, expensive)
val embeddings = documents.map { doc =>
embedder.embed(EmbeddingRequest(List(doc), embeddingModel))
}
7. Monitor Token Usage
Track costs in production:
1
2
3
4
5
6
7
8
9
10
val response = client.complete(Conversation(Seq(UserMessage("Hello"))))
response match {
case Right(completion) =>
// Note: usage returns Option[TokenUsage], so these are Option[Int]
println(s"Prompt tokens: ${completion.usage.map(_.promptTokens)}")
println(s"Completion tokens: ${completion.usage.map(_.completionTokens)}")
println(s"Total tokens: ${completion.usage.map(_.totalTokens)}")
case Left(error) => println(s"Error: $error")
}
Running the Examples
Note: Use the same Main pattern shown above to run the ConversationExample, ToolExample, and StreamingExample classes.
Next Steps
Great job! You’ve written your first LLM4S programs. Now explore:
- Configuration → - Learn about advanced configuration
- Agent Framework → - Build sophisticated agents
- Tool Calling → - Create custom tools
- Examples → - Browse 69 working examples
Learning Resources
- Basic Examples - More beginner-friendly examples
- User Guide - Comprehensive feature guide
- API Reference - Detailed API documentation
- Discord Community - Get help and share ideas
Ready to dive deeper? Configure your environment →