Production Deployment Guide
This guide covers deploying LLM4S applications to production environments. It’s specific to LLM4S patterns—not general application deployment advice.
Overview
A production-ready LLM4S application needs to address:
- Configuration & Secrets - Safe handling of API keys and provider credentials
- Provider Reliability - Graceful handling of rate limits, timeouts, and failures
- Resource Management - Proper lifecycle handling for clients and connections
- Observability - Tracing, logging, and monitoring for production visibility
- Cost Control - Token usage awareness and caching strategies
LLM4S follows a configuration boundary principle: all configuration loading happens at the application edge, and core code receives typed settings via dependency injection.
Configuration & Secrets
Never Commit Secrets
API keys belong in environment variables or a secrets manager—never in source control. A section’s
key is its own apiKey when it sets one, and otherwise its vendor’s shared
llm4s.credentials.<provider>.apiKey, which each provider module binds to the vendor’s variable
(OPENAI_API_KEY, ANTHROPIC_API_KEY, …; see API keys).
That default is convenient in development. In production, give every section its own apiKey:
a section meant for a second account whose key is missing would otherwise fall back to the shared
key and silently bill the default account. llm4s logs at INFO which path each key came from
(llm4s.providers.openai-main: API key from llm4s.providers.openai-main.apiKey), never the value.
Nothing reads LLM_MODEL.
1
2
3
# .env (add to .gitignore)
OPENAI_API_KEY=<your-openai-key>
ANTHROPIC_API_KEY=<your-anthropic-key>
Configuration Hierarchy
LLM4S reads configuration through PureConfig’s default source, in this order (highest to lowest precedence):
- System properties (
-Dllm4s.providers.provider=claude) - application.conf (HOCON in
src/main/resources/, or the file named by-Dconfig.file/-Dconfig.resource) - reference.conf (the defaults shipped in each llm4s module)
Environment variables are not a layer of their own: one is read only where a ${?VAR} substitution
binds it - in your application.conf, or in a module’s reference.conf (tracing, embeddings,
tools and each provider’s vendor key bind theirs there; see
the variables llm4s reads).
Production application.conf
Create src/main/resources/application.conf with a named section per provider, each binding its
own key explicitly, so which account a section bills is written down:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
llm4s {
providers {
provider = "openai-main" # the default: the name of a section below
provider = ${?LLM4S_PROVIDER} # optional override from the environment (your binding)
openai-main {
provider = "openai"
model = "gpt-4o"
apiKey = ${?OPENAI_API_KEY} # explicit: this section bills the OPENAI_API_KEY account
}
claude {
provider = "anthropic"
model = "claude-sonnet-4-20250514"
apiKey = ${?ANTHROPIC_API_KEY}
}
}
# Tracing already binds TRACING_MODE (llm4s-core), LANGFUSE_* (llm4s-observability)
# and OTEL_SERVICE_NAME / OTEL_EXPORTER_OTLP_ENDPOINT (llm4s-observability-otel) in
# each module's reference.conf. OTLP exporter headers come from this map, not from
# OTEL_EXPORTER_OTLP_HEADERS:
# tracing.opentelemetry.headers { Authorization = ${?OTEL_AUTH_HEADER} }
}
Each provider comes from its own module (llm4s-openai, llm4s-anthropic, …); add the ones your
sections name. On 0.4.1 and earlier they all ship inside llm4s-core.
Only the section you load is validated. Llm4sConfig.defaultProvider() checks the default section
and Llm4sConfig.provider("claude") checks claude; a section with no key available, or
whose provider module is not on the classpath, fails only when it is the one asked for. The file
above can therefore be deployed with just OPENAI_API_KEY set while openai-main is the default.
(Up to 0.4.1 every section was validated on every load, so it needed both keys.)
Check for inherited keys with the config policy
llm4s-config-policy’s prod preset (ConfigPolicy.prodSafeDefaults) includes the rule
ownApiKey: every named chat section whose provider needs a key must set its own apiKey. A
section that does not - and would therefore use the vendor’s shared key - fails the check, whether
or not the shared variable is set where the check runs:
1
- [ownApiKey] llm4s.providers.openai-batch sets no apiKey, so it would use the shared llm4s.credentials.openai.apiKey; set llm4s.providers.openai-batch.apiKey to the key for the account it should bill
Run it in CI against your production config:
sbt "configPolicy/runMain org.llm4s.configpolicy.CheckPolicies --env=prod --config prod.conf".
The rule is off in the dev preset; enable it in a custom policy with
withOwnApiKeyRequired(CatalogEnvironment.Prod). Llm4sConfig.apiKeySources() reports the same
information - ApiKeySource.Section or ApiKeySource.Credentials per section - for checks of your
own.
Per-Environment Configuration
Pick the provider per environment without changing code:
- Override the default with a system property:
-Dllm4s.providers.provider=claude. -
Bind the default yourself to a variable of your choosing:
1 2 3 4
llm4s.providers { provider = "openai-main" provider = ${?LLM4S_PROVIDER} # your binding; any name works }
-
Ship one file per environment and select it at startup, if you prefer each environment’s file to hold only the sections it uses:
1 2
java -Dconfig.resource=prod.conf -jar app.jar # src/main/resources/prod.conf java -Dconfig.file=/etc/myapp/app.conf -jar app.jar # a file outside the jar
- Load a section by name where one application uses several:
Llm4sConfig.provider("claude").
Configuration Boundary Pattern
LLM4S enforces a strict configuration boundary. Core code never reads configuration directly—all PureConfig and environment access happens in org.llm4s.config, and typed settings are injected into your application.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.LLMConnect
import org.llm4s.model.ModelRegistryService
// At the application edge (main, controller, etc.)
val result = for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
tracingConfig <- Llm4sConfig.tracing()
client <- LLMConnect.getClient(providerConfig)
} yield (client, tracingConfig)
// Pass typed config into your services—don't call Llm4sConfig inside core logic
class MyService(client: LLMClient, tracingSettings: TracingSettings) {
// Use injected dependencies
}
This pattern makes testing easier and keeps configuration concerns at the edges.
Secrets in Kubernetes
For Kubernetes deployments, use Secrets and reference them in your pod spec. The variable names are
the ones your sections bind (apiKey = ${?OPENAI_API_KEY}) - or, for a section without its own
apiKey, the vendor’s variable its provider module binds; TRACING_MODE is bound by
llm4s-core’s reference.conf and LANGFUSE_* by llm4s-observability’s. Supply the key of each section the
deployment loads: with the file above and openai-main as the default, OPENAI_API_KEY, plus
ANTHROPIC_API_KEY if it also calls Llm4sConfig.provider("claude").
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
apiVersion: v1
kind: Secret
metadata:
name: llm4s-secrets
type: Opaque
stringData:
OPENAI_API_KEY: <your-openai-key>
ANTHROPIC_API_KEY: <your-anthropic-key> # the claude section is validated too
LANGFUSE_PUBLIC_KEY: <your-langfuse-public-key>
LANGFUSE_SECRET_KEY: <your-langfuse-secret>
---
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: app
envFrom:
- secretRef:
name: llm4s-secrets
env:
# Selects a section through the `provider = ${?LLM4S_PROVIDER}`
# binding in the application.conf above
- name: LLM4S_PROVIDER
value: "openai-main"
- name: TRACING_MODE
value: "langfuse"
For enterprise environments, consider HashiCorp Vault or AWS Secrets Manager with init containers or sidecar injection.
Provider Reliability
Rate Limits
LLM providers enforce rate limits. In production, expect and handle 429 Too Many Requests:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import org.llm4s.error.{RateLimitError, LLMError}
import scala.concurrent.duration._
def callWithBackoff(
client: LLMClient,
conversation: Conversation,
maxRetries: Int = 3
): Result[Completion] = {
def attempt(remaining: Int, delay: FiniteDuration): Result[Completion] = {
client.complete(conversation) match {
case Left(RateLimitError(_, retryAfter, _)) if remaining > 0 =>
// retryAfter is the provider's Retry-After hint, already a FiniteDuration
Thread.sleep(retryAfter.getOrElse(delay).toMillis)
attempt(remaining - 1, delay * 2)
case other => other
}
}
attempt(maxRetries, 1.second)
}
Timeout Configuration
Set reasonable timeouts at multiple levels:
1
2
3
4
5
6
# application.conf
akka.http.client {
connecting-timeout = 10s
idle-timeout = 60s
}
Provider Fallbacks (Planned)
LLM4S doesn’t yet have built-in provider fallback, but you can implement it:
1
2
3
4
5
6
7
8
9
10
def withFallback(
primary: LLMClient,
fallback: LLMClient,
conversation: Conversation
): Result[Completion] = {
primary.complete(conversation) match {
case Left(_) => fallback.complete(conversation)
case success => success
}
}
Multi-provider resilience (circuit breakers, automatic failover) is planned for a future release.
Validate on Startup
Call client.validate() during application startup to fail fast on misconfiguration:
1
2
3
4
5
6
7
8
9
10
11
val client = LLMConnect.getClient(config).flatMap { c =>
c.validate().map(_ => c)
}
client match {
case Left(error) =>
logger.error(s"LLM client validation failed: $error")
System.exit(1)
case Right(c) =>
// Proceed with healthy client
}
Resource Management
LLMClient Lifecycle
LLMClient holds HTTP connections and thread pools. Create it once at startup and close it on shutdown:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.{LLMClient, LLMConnect}
import org.llm4s.model.ModelRegistryService
class Application {
private var client: Option[LLMClient] = None
def start(): Unit = {
client = (for {
providerConfig <- Llm4sConfig.defaultProvider()
registry <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registry
c <- LLMConnect.getClient(providerConfig)
} yield c) match {
case Right(c) => Some(c)
case Left(_) => None
}
}
def shutdown(): Unit = {
client.foreach(_.close())
}
}
Using AutoCloseable
For scoped usage, leverage Scala’s Using:
1
2
3
4
5
6
7
8
9
10
11
import scala.util.Using
Using.resource(
LLMConnect.getClient(config) match {
case Right(c) => c
case Left(e) => throw new RuntimeException(e.toString)
}
) { client =>
// Client is automatically closed after this block
client.complete(conversation)
}
Framework Integration
Akka/Pekko:
1
2
3
4
5
6
7
8
9
10
11
import akka.actor.CoordinatedShutdown
CoordinatedShutdown(system).addTask(
CoordinatedShutdown.PhaseServiceStop,
"close-llm-client"
) { () =>
Future {
client.close()
Done
}
}
Play Framework:
1
2
3
4
5
6
7
8
9
import play.api.inject.ApplicationLifecycle
class LLMModule @Inject()(lifecycle: ApplicationLifecycle) {
val client: LLMClient = // ...
lifecycle.addStopHook { () =>
Future.successful(client.close())
}
}
ZIO:
1
2
3
4
5
6
7
8
import zio._
val clientLayer: ZLayer[Scope, LLMError, LLMClient] =
ZLayer.scoped {
ZIO.acquireRelease(
ZIO.fromEither(LLMConnect.getClient(config))
)(client => ZIO.succeed(client.close()))
}
Workspace Execution
For workspace-based execution (containerized command execution), the ContainerisedWorkspace manages its own lifecycle:
1
2
3
4
5
6
7
8
9
10
11
import scala.util.Using
import org.llm4s.workspace.ContainerisedWorkspace
// ContainerisedWorkspace does not extend AutoCloseable — define a Releasable
implicit val workspaceReleasable: Using.Releasable[ContainerisedWorkspace] =
(ws: ContainerisedWorkspace) => ws.stopContainer()
Using.resource(new ContainerisedWorkspace("/app/workspace", "llm4s-runner:latest", 8090)) { workspace =>
// Execute a shell command inside the isolated container
workspace.executeCommand("python main.py")
}
Observability
Tracing Modes
LLM4S supports four tracing modes:
| Mode | Use Case | Configuration |
|---|---|---|
langfuse |
Production monitoring | TRACING_MODE=langfuse |
opentelemetry |
OpenTelemetry tracing | TRACING_MODE=opentelemetry |
console |
Development/debugging | TRACING_MODE=console |
noop |
Disabled | TRACING_MODE=noop |
Langfuse Setup
Langfuse provides production-grade LLM observability. Its backend ships in llm4s-observability
("org.llm4s" %% "llm4s-observability" % llm4sVersion); on 0.4.1 and earlier it is part of
llm4s-core:
1
2
3
4
TRACING_MODE=langfuse
LANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>
LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>
LANGFUSE_URL=https://cloud.langfuse.com # or self-hosted
Both keys are required: with either unset, Tracing.create logs a ConfigurationError naming it
(llm4s.tracing.langfuse.publicKey (LANGFUSE_PUBLIC_KEY)) and falls back to no tracing, so check
startup logs, or build the tracer with Tracing.fromSettings to fail fast.
What gets traced:
- Traces - Top-level request lifecycle
- Generations - Each LLM call with model, tokens, latency
- Spans - Tool executions, retrieval operations
- Events - User inputs, errors, custom markers
Example trace structure for a RAG query:
1
2
3
4
5
6
7
8
Trace: "RAG Query Processing"
├── Span: "Document Retrieval" (200ms)
│ └── Event: "Retrieved 5 documents"
├── Generation: "RAG Response" (1200ms)
│ ├── Model: gpt-4o
│ ├── Input tokens: 1,234
│ └── Output tokens: 456
└── Event: "Final Response"
Structured Logging
LLM4S uses SLF4J. Configure your logging backend (Logback, Log4j2) for JSON output in production:
1
2
3
4
5
6
7
8
9
10
11
12
<!-- logback.xml -->
<configuration>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<logger name="org.llm4s" level="INFO"/>
<root level="WARN">
<appender-ref ref="JSON"/>
</root>
</configuration>
Health Checks
Expose health endpoints that verify LLM connectivity:
1
2
3
// Integrate with your framework's health check mechanism
def isLLMHealthy(): Boolean =
client.validate().isRight
Deployment Patterns
Single-Node (Development/Small Scale)
Suitable for experiments, small teams, or low-traffic applications:
1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────────────────────┐
│ Application │
│ ┌─────────────┐ ┌──────────────┐ │
│ │ LLM4S Core │ │ Tracing │ │
│ │ │ │ (Console) │ │
│ └─────────────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Ollama │ (or cloud provider)│
│ └─────────────┘ │
└─────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
# application.conf
llm4s.providers {
provider = "ollama-local"
ollama-local {
provider = "ollama"
model = "llama3.2"
baseUrl = "http://localhost:11434" # required for Ollama
}
}
1
TRACING_MODE=console # bound by llm4s-core's reference.conf; console is also the default
Kubernetes (Production)
Standard production deployment with observability:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌──────────────────────────────────────────────────┐
│ Kubernetes Cluster │
│ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ App Pod │ │ App Pod │ │ App Pod │ │
│ │ (LLM4S) │ │ (LLM4S) │ │ (LLM4S) │ │
│ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ │
│ │ │ │ │
│ └───────────────┼───────────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐│
│ │ Langfuse (Tracing) ││
│ └─────────────────────────────────────────────┘│
│ │ │
└────────────────────────┼────────────────────────┘
▼
┌──────────────────────┐
│ LLM Provider API │
│ (OpenAI/Anthropic) │
└──────────────────────┘
Key considerations:
- Store secrets in Kubernetes Secrets or external vault
- Use horizontal pod autoscaling based on request queue depth (not CPU)
- Configure appropriate resource limits for memory-intensive operations
- Set up liveness/readiness probes that include LLM connectivity
Enterprise VPC
For regulated industries or multi-tenant deployments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────────────┐
│ Private VPC │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ App Tier │───▶│ LLM Proxy │───▶│ Audit Log │ │
│ │ (LLM4S) │ │ (Rate Lim) │ │ (S3/ELK) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │ │
│ │ ┌──────┴──────┐ │
│ │ ▼ ▼ │
│ │ ┌──────────┐ ┌──────────┐ │
│ │ │ Vault │ │ Langfuse │ │
│ │ │ (Secrets)│ │ (Self- │ │
│ │ └──────────┘ │ hosted) │ │
│ │ └──────────┘ │
│ ▼ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Azure OpenAI (Private Endpoint) │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Key considerations:
- Use Azure OpenAI with Private Link or AWS Bedrock for data residency
- Deploy self-hosted Langfuse within your VPC
- Implement centralized audit logging for compliance
- Use HashiCorp Vault for secrets rotation
Scaling & Cost Control
Token Budgets
Use LLMClient.getContextBudget() to stay within limits:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.llm4s.agent.AgentState
import org.llm4s.agent.ContextWindowConfig
import org.llm4s.toolapi.ToolRegistry
val budgetTokens = client.getContextBudget(HeadroomPercent.Standard)
// Prune conversation using the AgentState pruning API
val state = AgentState(conversation, ToolRegistry.empty)
val prunedConversation =
AgentState.pruneConversation(
state,
ContextWindowConfig(maxTokens = Some(budgetTokens))
)
Conversation Pruning
For long-running conversations, use built-in pruning strategies:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.llm4s.agent.AgentState
import org.llm4s.agent.ContextWindowConfig
import org.llm4s.agent.PruningStrategy
import org.llm4s.toolapi.ToolRegistry
// Prune when context exceeds configured limits
val state = AgentState(conversation, ToolRegistry.empty)
val prunedConversation =
AgentState.pruneConversation(
state,
ContextWindowConfig(
maxMessages = Some(50),
pruningStrategy = PruningStrategy.OldestFirst
)
)
Caching Considerations
LLM4S includes a CachingLLMClient wrapper for basic caching, but production deployments may require external caching (Redis, semantic cache, etc.) depending on scale:
- Embedding cache - Store computed embeddings in Redis/Memcached
- Response cache - Cache identical prompts (careful with cache invalidation)
- Semantic cache - Use vector similarity to find cached similar queries
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Example: Simple response caching (implement based on your cache backend)
def cachedComplete(
client: LLMClient,
conversation: Conversation,
cache: Cache[String, Completion]
): Result[Completion] = {
val key = conversation.hashCode.toString
cache.get(key) match {
case Some(cached) => Right(cached)
case None =>
client.complete(conversation).map { completion =>
cache.put(key, completion)
completion
}
}
}
Cost Estimation
Track token usage through tracing. With Langfuse, you get automatic cost calculation when model pricing is configured.
For manual tracking:
1
2
3
4
5
6
7
8
completion.usage match {
case Some(usage) =>
val inputCost = usage.promptTokens * MODEL_INPUT_PRICE_PER_1K / 1000
val outputCost = usage.completionTokens * MODEL_OUTPUT_PRICE_PER_1K / 1000
logger.info(s"Request cost: $$${inputCost + outputCost}")
case None =>
logger.warn("Usage data not available")
}
Production Checklist
Before deploying to production, verify:
Configuration
- API keys are in environment variables or secrets manager (not in code)
application.confuses${?VAR}substitution for all secrets- Provider configuration validated on startup (
client.validate()) - Tracing mode set to
langfuse(notconsole)
Reliability
- Retry logic implemented for rate limits (429 errors)
- Timeouts configured for HTTP clients
- Graceful shutdown hooks registered for
LLMClient.close() - Health check endpoint includes LLM connectivity
Observability
- Langfuse credentials configured and tested
- Structured logging enabled (JSON format)
- Log levels appropriate (INFO for
org.llm4s, WARN for root) - Metrics exported (Prometheus/StatsD if applicable)
Security
- API keys rotatable without code changes
- Secrets not logged (check log output for key patterns)
- Input validation in place for user-provided prompts
- Rate limiting at application level (not just provider)
Cost & Performance
- Token budgets configured per request type
- Conversation pruning enabled for long sessions
- Model selection appropriate for use case (don’t use GPT-4 where GPT-3.5 suffices)
- Embedding caching considered for RAG workloads
Operations
- Deployment runbook documented
- Rollback procedure tested
- Alerting configured for error rates and latency
- On-call rotation aware of LLM-specific failure modes
Related Documentation
- Configuration Guide - Complete configuration reference
- Configuration Boundary - Architecture pattern explanation
- Langfuse Workflow Patterns - Tracing event sequences
- Roadmap - Planned reliability and security features
- Agent Framework - Agent lifecycle and state management
Known Limitations (v0.1.x)
Current limitations to be aware of in production:
- No built-in circuit breaker - Implement at application level or use Resilience4j
- No automatic provider fallback - Must implement manually
- Tool registries not serializable - Reconstruct on
AgentStaterestore - Advanced semantic/embedding caching not included - Add Redis/vector cache for high-volume RAG
These are tracked for improvement in the Production Readiness Roadmap.
This guide will evolve as LLM4S approaches v1.0.
Getting Help
- Discord: Join the community
- GitHub Issues: Report problems
- Examples: Production-like samples