Basic Usage Guide
Learn the fundamentals of LLM4S: creating clients, making LLM calls, and handling results.
Table of contents
- Getting Started with LLM Calls
- Simple Client Creation Examples
- Understanding Result Types and Error Handling
- Message Management
- Configuration Methods
- Common Patterns
- Troubleshooting
Getting Started with LLM Calls
LLM4S makes it simple to integrate Large Language Models into your Scala applications. The core workflow is:
- Configure a named provider section in your
application.conf - Create an LLM client
- Send messages to the LLM
- Handle the result (success or error)
Let’s walk through each step.
Simple Client Creation Examples
Minimal Example
The simplest way to get an LLM response:
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
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.{LLMClient, LLMConnect}
import org.llm4s.llmconnect.model.{Conversation, UserMessage}
import org.llm4s.model.ModelRegistryService
object SimpleExample extends App {
// Step 1: Load the configured default named provider
val startup = for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
} yield {
// Step 2: Create a simple message
val conversation = Conversation(Seq(UserMessage("What is Scala?")))
// Step 3: Get a response
val response = client.complete(conversation)
// Step 4: Handle the result
response match {
case Right(completion) =>
println(s"Response: ${completion.content}")
case Left(error) =>
println(s"Error: $error")
}
}
// Execute and report any startup errors
startup.left.foreach(err => println(s"Startup Error: $err"))
}
The fragments in the rest of this guide use the imports of this example, plus org.llm4s.types.Result
(and org.llm4s.error.LLMError where they name it), and a client built as above.
Before running, configure a provider 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"
}
}
}
and export the vendor’s key, which llm4s-openai binds for any OpenAI section without an
apiKey of its own:
1
export OPENAI_API_KEY=sk-proj-...
For Anthropic, use a section with provider = "anthropic", export ANTHROPIC_API_KEY, and name
the section in provider. llm4s reads no LLM_MODEL. See
Named provider sections and
API keys.
Multi-Provider Pattern
LLM4S resolves whichever named provider you configure as the default:
1
2
3
4
5
6
7
8
9
10
// Written against the LLMClient interface, not a provider
def processWithAnyProvider(client: LLMClient): Result[Completion] =
client.complete(Conversation(Seq(UserMessage("What is Scala?"))))
val startup = for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
} yield processWithAnyProvider(client)
The same code works with any configured named provider without modifications: change
llm4s.providers.provider, or load another section with Llm4sConfig.provider("<name>"). See
Switching providers.
With Explicit Model Selection
The model comes from the section’s model; to use another one, load a section that names it with
Llm4sConfig.provider("<name>"). To adjust the generation settings of one call, pass CompletionOptions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val startup = for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
} yield {
val conversation = Conversation(Seq(UserMessage("Tell me about Scala")))
// Adjust generation settings while keeping the configured default provider
val response = client.complete(
conversation,
CompletionOptions(maxTokens = Some(512))
)
response.map(completion => println(completion.content))
}
Understanding Result Types and Error Handling
The Result Type
In LLM4S, operations return Result[A] instead of throwing exceptions. This is a type alias for Either[LLMError, A]:
1
type Result[+A] = Either[LLMError, A]
This approach provides:
- Type-safe error handling - Errors are part of the type signature
- No surprise exceptions - All failures are explicit
- Composable operations - Easy to chain operations with
forcomprehensions
Pattern Matching on Results
The most common approach is pattern matching:
1
2
3
4
5
6
7
8
9
10
11
12
val result: Result[Completion] = client.complete(conversation)
result match {
case Right(completion) =>
// Success! Access the response
println(s"Content: ${completion.content}")
println(s"Model: ${completion.model}")
case Left(error) =>
// Handle the error
println(s"Error: ${error.message}")
println(s"Type: ${error.getClass.getSimpleName}")
}
LLM Errors
All LLM operations return an LLMError on failure. It is an open trait in org.llm4s.error
with a message, an optional code and a context map, and each subtype is either
recoverable (worth retrying) or not:
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.error._
def handle(error: LLMError): Unit = error match {
case _: AuthenticationError => // the key was rejected: fix the credentials
case _: RateLimitError => // wait, then retry
case _: NetworkError => // transient: retry
case _: ConfigurationError => // missing or invalid configuration
case other => println(other.formatted)
}
// true for a RecoverableError (RateLimitError, NetworkError, TimeoutError, ...), false otherwise
def retryable(error: LLMError): Boolean = LLMError.isRecoverable(error)
isRecoverable answers for every error. One that carries neither marker (EmbeddingError, RerankError,
a custom LLMError that does not say) is not recoverable.
See the Error Handling guide for every error type, when it is raised, and how to handle, convert and test them.
Using For-Comprehensions
For cleaner code, use Scala’s for comprehensions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
val result = for {
// Configure and create client
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(providerConfig)
// Make the LLM call — complete takes a Conversation and optional CompletionOptions
completion <- client.complete(
Conversation(Seq(UserMessage("Hello, LLM!")))
)
} yield {
// All operations succeeded - work with the completion
completion.content
}
// Execute
result match {
case Right(content) => println(s"Success: $content")
case Left(error) => println(s"Failed: ${error.message}")
}
This is much cleaner than nested pattern matches!
Converting from Try to Result
If you have code using Try, convert it with the TryOps extension:
1
2
3
4
5
import org.llm4s.types.TryOps
import scala.util.Try
val tryValue = Try("123".toInt)
val result = tryValue.toResult // Result[Int]
Message Management
User Messages
Send simple user queries:
1
2
val userMsg = UserMessage("What is Scala?")
val response = client.complete(Conversation(Seq(userMsg)))
Assistant Messages
Include assistant responses for multi-turn conversations:
1
2
3
4
5
6
7
val conversation = Conversation(Seq(
UserMessage("What is Scala?"),
AssistantMessage("Scala is a functional programming language..."),
UserMessage("Tell me more about its type system")
))
val response = client.complete(conversation)
System Messages
Prepend a SystemMessage to the conversation to set context and behavior:
1
2
3
4
5
6
val conversation = Conversation(Seq(
SystemMessage("You are a Scala expert. Answer concisely."),
UserMessage("What is a monad?")
))
val response = client.complete(conversation)
To build the messages from typed values, with untrusted text (a document, an email, a tool’s result) fenced off from
your instructions, use the prompt"..." and system"..." interpolators of llm4s-prompt: see
Typed Prompts.
Configuration Methods
application.conf (Recommended)
Providers are named sections under llm4s.providers in your src/main/resources/application.conf,
with secrets bound from the environment by ${?VAR}:
1
2
3
4
5
6
7
8
9
10
llm4s {
providers {
provider = "openai-main"
openai-main {
provider = "openai"
model = "gpt-4o"
}
}
}
1
export OPENAI_API_KEY=sk-...
Environment Variables
llm4s reads an environment variable only where a ${?VAR} binding names it - in your own
application.conf, or in a module’s reference.conf. A few settings are bound for you,
for example:
1
2
3
export OPENAI_API_KEY=sk-... # llm4s.credentials.openai.apiKey
export TRACING_MODE=console # llm4s.tracing.mode
export EMBEDDING_MODEL=openai/text-embedding-3-small # llm4s.embeddings.model
LLM_MODEL is not among them. See
Environment variables llm4s reads.
System Properties
JVM system properties override application.conf, key for key:
1
2
3
java -Dllm4s.providers.provider=claude \
-Dllm4s.providers.openai-main.model=gpt-4o-mini \
-jar app.jar
Common Patterns
Handling Invalid Configuration
Always check startup results:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
val startup = for {
config <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
client <- LLMConnect.getClient(config)
} yield client
startup match {
case Right(client) =>
// Use the client
val result = client.complete(Conversation(Seq(UserMessage("Hello!"))))
case Left(error) =>
// Configuration failed - log and exit
System.err.println(s"Startup failed: ${error.message}")
System.exit(1)
}
Retrying Failed Calls
For transient failures, implement retry logic:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def retryWithBackoff[T](maxRetries: Int = 3)(
operation: () => Result[T]
): Result[T] = {
(1 until maxRetries).foldLeft(operation()) { (acc, attempt) =>
if (acc.isRight) acc
else {
Thread.sleep(math.pow(2, attempt.toDouble).toLong * 100)
operation()
}
}
}
// Usage
val response = retryWithBackoff(3) { () =>
client.complete(Conversation(Seq(UserMessage("Hello!"))))
}
Logging Errors
Use SLF4J for consistent logging:
1
2
3
4
5
6
7
8
9
10
11
12
import org.slf4j.LoggerFactory
val logger = LoggerFactory.getLogger(getClass)
val response = client.complete(Conversation(Seq(UserMessage("Hello!"))))
response match {
case Right(completion) =>
logger.info(s"Got response: ${completion.content}")
case Left(error) =>
// LLMError is not a Throwable, so pass only the message string
logger.error(s"LLM call failed: ${error.message}")
}
Troubleshooting
“Invalid API Key”
- Verify
OPENAI_API_KEY(or the variable the section’s ownapiKeybinds, which wins) is set and correct - Check the API key has the right permissions on the provider’s dashboard
- Ensure no extra whitespace in the key
“Model not found”
- Verify the model name matches the provider’s API
- Check MODEL_METADATA.md for available models
- Some models may not be available in your region or account
“Configuration not found”
- Ensure
application.conf(insrc/main/resources) has a section underllm4s.providersand thatllm4s.providers.providernames it -LLM_MODELis not read - Ensure the vendor’s variable (or the one the section’s own
apiKeybinds) is exported in the shell that starts the JVM - Check the error names the section you meant to load:
defaultProvider()loads the onellm4s.providers.providernames, and only that section is validated
“Connection timeout”
- Verify internet connectivity
- Check if the provider’s service is operational
- Try using a different network or VPN
- Raise the provider’s timeout in configuration if needed: a section’s
timeouts { request = 5m }block (see Timeouts)