# LLM4S 0.5.1 documentation > The getting-started pages, the user guide, the cookbook and the Java and Kotlin guides of https://llm4s.org, release 0.5.1, in navigation order. A Scala block under a page marked `not-compile-checked` is followed by `(not compile-checked)`: other blocks are compiled against the release. --- Source: https://llm4s.org/getting-started/ # Getting Started Get up and running with LLM4S in minutes. This section will guide you through: 1. **[Installation](installation)** - Set up LLM4S in your project 2. **[First Example](first-example)** - Write your first LLM-powered program 3. **[Configuration](configuration)** - Configure providers and API keys 4. **[Next Steps](next-steps)** - Choose your learning path 5. **[Ollama Quick Start](ollama-quickstart)** - Local LLM development (no API keys needed) 6. **[Gradle Integration](gradle)** - Use LLM4S from a Gradle (Java or Kotlin) build 7. **[Scala CLI Quick Start](scala-cli)** - A first LLM call from a single file, with no sbt project 8. **[Maven Quick Start](maven)** - A complete `pom.xml` that runs a first call from Java with `llm4s-java-api` 9. **[JBang Quick Start](jbang)** - A first LLM call from one Java file, with no build tool 10. **[Mill Quick Start](mill)** - A `build.mill` project with a first call and a test that needs no API key 11. **From Java, Spring Boot or Kotlin** - the [Java guide](/guide/java), the [Spring Boot starter](/guide/spring-boot) and [Kotlin coroutines](installation#for-kotlin-coroutines-and-flow) (`llm4s-kotlin-api` 0.5.1 is not on Maven Central yet; that section says how to build it) 12. **[First-Run Troubleshooting](troubleshooting)** - Symptoms, causes and fixes for the problems a first run most often hits 13. **[Using LLM4S from Scala 2.13](scala-2-13)** - Call LLM4S from a Scala 2.13 project through the TASTy reader The current release is **0.5.1**, for Scala 3.7.x on JDK 21. ## Quick Start The fastest way to get started: ```bash # 1. Use the starter template, on the current release sbt new llm4s/llm4s.g8 --llm4s_version=0.5.1 # 2. Set the API key - llm4s-openai reads it for the template's openai-main section export OPENAI_API_KEY=sk-... # 3. Run your first program sbt run ``` Check that the generated `build.sbt` lists a provider module (such as `llm4s-openai`) next to `llm4s-core`: since 0.5.0 `llm4s-core` ships none. The template update for 0.5.x ([llm4s.g8#6](https://github.com/llm4s/llm4s.g8/pull/6)) adds `llm4s-ollama` and `llm4s-openai` and defaults to a local Ollama section (choose OpenAI with `LLM4S_PROVIDER=openai-main`); an older template lists `llm4s-core` only, so add the provider yourself ([Installation](installation#quick-start-with-the-starter-kit) shows how). llm4s reads providers from named sections in `application.conf`, not from variables of its own; see [Configuration](configuration#named-provider-sections) to add or switch providers. ## What You'll Learn By the end of this section, you'll: - ✅ Have LLM4S installed and configured - ✅ Understand Result-based error handling - ✅ Know how to make basic LLM calls - ✅ Be able to configure multiple providers - ✅ Know where to go next based on your goals ## Prerequisites Before starting, you should have: - **JDK 21** - **Scala 3.7.x** (llm4s is built with 3.7.1) - **sbt 1.10.6+** - An API key from OpenAI, Anthropic, Azure OpenAI or another provider, or [Ollama](ollama-quickstart) installed ## Time to Complete - **Quick Start**: ~5 minutes (with starter kit) - **Full Tutorial**: ~30 minutes ## Need Help? If you get stuck: - Check the [troubleshooting guides](configuration#troubleshooting) - Ask in [Discord](https://discord.gg/4uvTPn6qww) - Browse [examples](/examples/) --- **Ready?** [Start with installation →](installation) --- Source: https://llm4s.org/getting-started/installation # Installation Get LLM4S up and running in minutes. --- ## Prerequisites Before installing LLM4S, ensure you have: - **Java Development Kit (JDK) 21** - **Scala 3.7.x** (llm4s 0.5.1 is built with 3.7.1) - Scala 2.13: call-only, via the TASTy reader; see [Using LLM4S from Scala 2.13](scala-2-13) - **sbt 1.10.6** or higher - An API key from at least one LLM provider (OpenAI, Anthropic, Azure OpenAI, ...), or a local [Ollama](ollama-quickstart) server, which needs none ### Verify Prerequisites ```bash # Check Java version java -version # Should show 21 # Check Scala version scala -version # 3.7.1 # Check SBT version sbt version # 1.10.6 or higher ``` --- ## Add LLM4S to Your Project > **Artifact coordinates changed in 0.4.0.** Every published module now carries an > `llm4s-` prefix (`core` → `llm4s-core`, `workspaceClient` → `llm4s-workspace-client`, > and so on). Releases up to and including `0.3.4` remain available under the old names. > See the [migration guide](/reference/migration#artifact-coordinate-rename-v040) for the > full old → new table. ### SBT Add LLM4S to your `build.sbt`: ```scala // Scala 3 ThisBuild / scalaVersion := "3.7.1" libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-core" % "0.5.1", "org.llm4s" %% "llm4s-openai" % "0.5.1" // or the provider module you use ) ``` `llm4s-core` holds no provider client: add the module of each provider you call (`llm4s-openai`, `llm4s-anthropic`, `llm4s-gemini`, `llm4s-ollama`, ...; see [Optional Dependencies](#optional-dependencies)). Without one, loading a provider section fails because no module registers that provider. The Maven and Gradle snippets below show `llm4s-core` only; add the provider module the same way, with the `_3` suffix (`llm4s-openai_3`). ### Maven ```xml org.llm4s llm4s-core_3 0.5.1 ``` For Java, the [Maven quick start](maven) has a complete `pom.xml` for `llm4s-java-api_3`. ### Gradle (Kotlin DSL) Gradle does **not** resolve Scala cross-version suffixes automatically — you must append the `_3` suffix explicitly (LLM4S is Scala 3 only): ```kotlin // build.gradle.kts repositories { mavenCentral() } dependencies { implementation("org.llm4s:llm4s-core_3:0.5.1") } // Pin the Scala 3 library (scala3-library_3 only; scala-library stays at 2.13.x) configurations.all { resolutionStrategy.eachDependency { if (requested.group == "org.scala-lang" && requested.name == "scala3-library_3") { useVersion("3.7.1") } } } ``` ### Gradle (Groovy DSL) ```groovy // build.gradle repositories { mavenCentral() } dependencies { implementation 'org.llm4s:llm4s-core_3:0.5.1' } configurations.all { resolutionStrategy.eachDependency { details -> if (details.requested.group == 'org.scala-lang' && details.requested.name == 'scala3-library_3') { details.useVersion '3.7.1' } } } ``` > **Using Spring Boot, Ktor, or encountering dependency conflicts?** > See the full [Gradle integration guide](gradle) for dependency exclusion recipes > and the [dependency conflicts reference](/reference/dependency-conflicts). ### Scala CLI No build at all: declare the dependency in the file. ```scala //> using scala 3.7.1 //> using jvm 21 //> using dep org.llm4s::llm4s-core:0.5.1 //> using dep org.llm4s::llm4s-openai:0.5.1 ``` Add the provider module you use next to `llm4s-core` (`llm4s-openai` above). The [Scala CLI quick start](scala-cli) has a complete file that makes a first call. ### Mill In `build.mill` (Mill 1.x), `::` adds the Scala suffix as `%%` does in sbt: ```scala val llm4sVersion = "0.5.1" object app extends ScalaModule { def scalaVersion = "3.7.1" def mvnDeps = Seq( mvn"org.llm4s::llm4s-core:$llm4sVersion", mvn"org.llm4s::llm4s-openai:$llm4sVersion" ) } ``` The [Mill quick start](mill) has a complete project: a first call, its configuration, and a test that needs no API key. ### Multi-Module Project If you have a multi-module project: ```scala lazy val myProject = (project in file(".")) .settings( name := "my-llm-project", scalaVersion := "3.7.1", libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-core" % "0.5.1", "org.llm4s" %% "llm4s-openai" % "0.5.1" ) ) ``` ### Align versions with the BOM A project that uses several llm4s modules (a provider, `llm4s-rag`, `llm4s-observability`) can state the version once, in the bill of materials `llm4s-bom`, and leave it off every module. The BOM contains no code; it supplies the versions released together. Explicit overrides and dependency conflict resolution can still select different versions, so check the resolved dependency graph. Maven and Gradle have no notion of Scala cross-versioning, so the modules are written with the Scala binary suffix (`llm4s-core_3`), and the BOM itself has none (`llm4s-bom`). **Maven** (import scope): ```xml org.llm4s llm4s-bom 0.5.1 pom import org.llm4s llm4s-core_3 org.llm4s llm4s-openai_3 ``` **Gradle** (Kotlin DSL; in Groovy write `implementation platform("org.llm4s:llm4s-bom:...")`): ```kotlin dependencies { implementation(platform("org.llm4s:llm4s-bom:0.5.1")) implementation("org.llm4s:llm4s-core_3") implementation("org.llm4s:llm4s-openai_3") } ``` Gradle `platform(...)` treats the BOM versions as recommendations that other dependencies can upgrade. For strict version pinning in an application, use `enforcedPlatform(...)` instead. Publishing libraries should generally keep `platform(...)` to avoid forcing their versions on consumers. See the [Gradle platform documentation](https://docs.gradle.org/current/userguide/platforms.html#sub:bom_import). **Mill** imports it with `bomMvnDeps`; the [Mill quick start](mill#aligning-versions-with-llm4s-bom) shows how. **sbt** has no BOM import, so the BOM is optional there: keep one `val` for the version, as the sections below do (`%%` adds the `_3` suffix itself): ```scala val llm4sVersion = "0.5.1" libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-core" % llm4sVersion, "org.llm4s" %% "llm4s-openai" % llm4sVersion ) ``` ### Unreleased changes (`main`) No snapshots are published: releases are cut from tags only, and nothing publishes between them. To try what is on `main`, build it and publish it to your local repository: ```bash git clone https://github.com/llm4s/llm4s && cd llm4s sbt publishLocal # for sbt; use `sbt publishM2` for Maven or Gradle (mavenLocal()) sbt version # prints the version it published, for example 0.5.1+12-abc12345-SNAPSHOT ``` Then depend on that version, for example `libraryDependencies += "org.llm4s" %% "llm4s-core" % "0.5.1+12-abc12345-SNAPSHOT"` (use the one `sbt version` printed). This guide describes the latest release; `main` may have modules and configuration that it does not have yet. --- ## Quick Start with the Starter Kit The fastest way to a working sbt project is the **llm4s.g8** template: ```bash sbt new llm4s/llm4s.g8 --llm4s_version=0.5.1 # Answer the remaining prompts (name, package, scala_version, ...) or accept the defaults cd sbt run ``` Pass `--llm4s_version` (or type the version at its prompt) so the project starts on 0.5.1, then check the generated `build.sbt`: it needs `llm4s-core` **and at least one provider module** at that version, because since 0.5.0 `llm4s-core` ships no provider. The template is being moved to 0.5.x ([llm4s.g8#6](https://github.com/llm4s/llm4s.g8/pull/6)), which adds `llm4s-ollama` and `llm4s-openai` itself; a template from before that defaults to llm4s `0.4.0` and lists `llm4s-core` only, so add the provider you use, for example `"org.llm4s" %% "llm4s-openai" % "0.5.1"`. The generated project has an sbt build, a logging backend, an `application.conf` with [named provider sections](configuration#named-provider-sections), a small sample program and its tests. [View the starter kit →](https://github.com/llm4s/llm4s.g8) --- ## Optional Dependencies > `llm4s-core` is the spine: types, configuration, the `LLMClient` interface and the tool API. Providers, the agent > runtime and everything below are separate modules, released at the same version. Check [Maven Central](https://central.sonatype.com/namespace/org.llm4s) for available artifacts. ### For RAG, vector stores, chunking, reranking and document extraction ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-rag" % llm4sVersion ``` Brings `llm4s-knowledgegraph` with it, along with Tika, POI, PDFBox, jsoup and the AWS S3 client — the document-extraction and loader dependencies that `llm4s-core` no longer carries. Package names are unchanged, so existing `org.llm4s.rag.*` imports keep working; see the [migration note](../reference/migration#slice-1-llm4s-rag-and-llm4s-knowledgegraph). To keep the vectors in an Elasticsearch or OpenSearch cluster you already run, add the Elasticsearch store module (Experimental), which holds both `ElasticsearchVectorStore` and `OpenSearchVectorStore`. It talks to the REST API through `llm4s-core`'s HTTP client, so it brings no client library; see [Elasticsearch](../guide/vector-store#elasticsearch) and [OpenSearch](../guide/vector-store#opensearch). ```scala // same version as llm4s-rag libraryDependencies += "org.llm4s" %% "llm4s-vectorstore-elasticsearch" % llm4sVersion ``` To keep them in Redis (Redis Stack, or Redis 8 and later) or Valkey (with `valkey-search`), add the Redis store (Experimental). It brings the Jedis client; see [Redis and Valkey](../guide/vector-store#redis-and-valkey). ```scala // same version as llm4s-rag libraryDependencies += "org.llm4s" %% "llm4s-vectorstore-redis" % llm4sVersion ``` ### For agent memory ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-memory" % llm4sVersion // only if you store memories in Postgres/pgvector libraryDependencies += "org.llm4s" %% "llm4s-memory-postgres" % llm4sVersion ``` `llm4s-memory` carries `MemoryStore`, the memory managers, and the in-memory and SQLite-backed stores; it adds sqlite-jdbc to your classpath and nothing else. `PostgresMemoryStore` lives in `llm4s-memory-postgres`, which brings HikariCP and the Postgres JDBC driver — the two dependencies `llm4s-core` no longer carries. Package names are unchanged, so existing `org.llm4s.agent.memory.*` imports keep working; see the [migration note](../reference/migration#slice-2-llm4s-memory-and-llm4s-memory-postgres). ### For MCP (Model Context Protocol) ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-mcp" % llm4sVersion ``` Carries the MCP client, server, transports (stdio, HTTP, SSE) and `MCPToolRegistry`. It adds no third-party dependency of its own. Package names are unchanged, so existing `org.llm4s.mcp.*` imports keep working; see the [migration note](../reference/migration#slice-3-llm4s-mcp). ### For speech (STT / TTS) ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-speech" % llm4sVersion ``` Carries speech-to-text (Vosk for offline recognition, Whisper), text-to-speech (Tacotron 2), and the audio IO, conversion and validation helpers. **This is the module that brings Vosk**, a 25 MB dependency that used to sit on every `llm4s-core` user's classpath — which is much of the point of the split. Package names are unchanged; see the [migration note](../reference/migration#slice-3-llm4s-speech). ### For Ollama (local models) ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-ollama" % llm4sVersion ``` Carries the Ollama chat client, the Ollama embedding provider and Ollama model listing. It adds no third-party dependency of its own. Adding it is all the registration there is: the module declares itself to the provider registry, so `provider = "ollama"` and `EMBEDDING_MODEL=ollama/` resolve with no code change. Without it, both fail with an error saying `ollama` is not registered and naming the providers that are. Package names are unchanged; see the [migration note](../reference/migration#slice-5-llm4s-ollama). ### For Gemini and Vertex AI ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-gemini" % llm4sVersion ``` Carries both of Google's chat providers: the Gemini API client and model listing, and the Vertex AI client with its OAuth authentication. It adds no third-party dependency of its own - Vertex AI authentication is implemented without a Google SDK. Adding it is all the registration there is: the module declares itself to the provider registry, so `provider = "gemini"` (or `"google"`) and `provider = "vertexai"` (or `"vertex"`) resolve with no code change. Without it, they fail with an error saying the provider is not registered and naming the providers that are. Package names are unchanged; see the [migration note](../reference/migration#slice-5-llm4s-gemini). ### For Anthropic ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-anthropic" % llm4sVersion ``` Carries the Anthropic Claude chat client and Anthropic model listing. **This is the module that brings the Anthropic Java SDK** (`com.anthropic:anthropic-java`), which used to sit on every `llm4s-core` user's classpath. Adding it is all the registration there is: the module declares itself to the provider registry, so `provider = "anthropic"` resolves with no code change. Without it, it fails with an error saying the provider is not registered and naming the providers that are. Package names are unchanged; see the [migration note](../reference/migration#slice-5-llm4s-anthropic). ### For OpenAI, Azure OpenAI and Requesty ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-openai" % llm4sVersion ``` Carries the three providers that share `OpenAIClient` - OpenAI, Azure OpenAI and Requesty - the OpenAI embedding provider, and OpenAI and Requesty model listing. **This is the module that brings OpenAI's Java SDK** (`com.openai:openai-java`, with OkHttp, Jackson and the Kotlin standard library), which serves Azure OpenAI as well. It replaced Microsoft's deprecated `com.azure:azure-ai-openai`, which used to sit on every `llm4s-core` user's classpath; `llm4s-core` depends on no vendor SDK. Adding it is all the registration there is: the module declares itself to the provider registry, so `provider = "openai"`, `"azure"` and `"requesty"`, and `EMBEDDING_MODEL=openai/`, resolve with no code change. Without it, they fail with an error saying the provider is not registered and naming the providers that are. OpenRouter, DeepSeek and Z.ai speak the OpenAI wire format with no SDK, and are in `llm4s-openai-compatible` (below), which this module depends on for `OpenAIConfig`. `llm4s-rag`'s `RAGConfig.default` embeds with `openai`, so a RAG pipeline built from the default needs this module too, or `.withEmbeddings(...)` naming a provider you do ship. Package names are unchanged; see the [migration note](../reference/migration#slice-5-llm4s-openai). ### For Azure OpenAI with Microsoft Entra ID ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-azure-identity" % llm4sVersion ``` Keyless authentication for Azure OpenAI ([#2274](https://github.com/llm4s/llm4s/issues/2274)): an `azure` section with `authentication = "entra-id"` takes Microsoft Entra ID tokens from Azure's `DefaultAzureCredential` (a managed identity, workload identity or `az login`) instead of an API key, and `AzureEntraId` turns any `TokenCredential` into the tokens a config or embedding provider takes. It brings Azure's `azure-identity` (MIT, with `azure-core`, MSAL4J, Netty and Reactor), which is why it is not part of `llm4s-openai`. See [Enterprise authentication](../guide/providers#azure-openai-with-microsoft-entra-id). ### For DeepSeek, Z.ai, OpenRouter, Mistral, Cohere and any OpenAI-compatible endpoint ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion ``` One SDK-free client for the OpenAI `/chat/completions` API, and the providers that run on it: DeepSeek (`provider = "deepseek"`), Z.ai (`"zai"`), OpenRouter (`"openrouter"`), Mistral (`"mistral"`), Cohere (`"cohere"`, through Cohere's OpenAI-compatibility API), xAI (`"xai"`), and a generic `"openai-compatible"` provider for any other endpoint that speaks the API - Groq, Together, Fireworks, NVIDIA NIM, OrcaRouter, a vLLM, LM Studio or llama.cpp server, an internal gateway - configured with a `baseUrl` and a `model`, and no code. See [OpenAI-compatible endpoints](../guide/providers#openai-compatible-endpoints) for a configuration recipe per provider. The module brings no dependency beyond `llm4s-core`, and adding it registers the providers. Package names are unchanged; see the [migration note](../reference/migration#slice-5-llm4s-openai-compatible), and for Mistral and Cohere [this one](../reference/migration#slice-5-mistral-cohere-and-voyage-leave-core-core-ships-no-provider). ### For AWS Bedrock ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-bedrock" % llm4sVersion ``` The AWS Bedrock chat provider (`provider = "bedrock"`), over the Converse and ConverseStream APIs, so one client reaches Claude, Llama, Mistral, Nova and Titan models. It brings the AWS SDK for Java v2 `bedrockruntime` artifact (Apache-2.0, the same SDK release train `llm4s-rag` uses for S3); `llm4s-core` gains no dependency. A section needs a `region` and a `model`: ```hocon llm4s.providers.claude-bedrock { provider = "bedrock" model = "anthropic.claude-3-5-sonnet-20241022-v2:0" region = ${?AWS_REGION} } ``` Credentials come from the AWS default credential chain (environment variables, `~/.aws`, an instance or task role), or from a `profile`, or from explicit `accessKeyId` / `secretAccessKey` (plus `sessionToken` for temporary credentials). ### For Voyage AI embeddings ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-voyage" % llm4sVersion ``` The Voyage AI embedding provider (`EMBEDDING_MODEL=voyage/`), with its `llm4s.embeddings.voyage` config block. It is the first community provider module under `modules/providers/`, and brings no dependency beyond `llm4s-core`. ### For Jina AI embeddings ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-jina" % llm4sVersion ``` The Jina AI embedding provider (`EMBEDDING_MODEL=jina/`), with its `llm4s.embeddings.jina` config block and a typed `JinaTask` (`retrieval.query`, `retrieval.passage`, ...). A community provider module under `modules/providers/`; it brings no dependency beyond `llm4s-core`. ### For Cohere embeddings ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-cohere" % llm4sVersion ``` The Cohere embedding provider (`EMBEDDING_MODEL=cohere/`), on Cohere's native `/v2/embed`, with its `llm4s.embeddings.cohere` config block and a typed `CohereInputType` (`search_document`, `search_query`, ...). Cohere chat is a different module: `llm4s-openai-compatible`. A community provider module under `modules/providers/`; it brings no dependency beyond `llm4s-core`. ### For the Jev decision model ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-jev" % llm4sVersion ``` A typed client for TypeSafe's Jev decision model: send a state and typed questions (Noul, Choice, Score), get typed answers. It is not a chat provider and returns no `Completion`. Configured under `llm4s.jev`, with the key in `TYPESAFE_API_KEY`. A community module under `modules/providers/`; it brings no dependency beyond `llm4s-core`. See [Jev decision model](../guide/jev). ### For IBM watsonx.ai ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-watsonx" % llm4sVersion ``` The IBM watsonx.ai chat provider (`provider = "watsonx"`), brings no dependency beyond `llm4s-core`. See [IBM watsonx.ai](../guide/providers#ibm-watsonxai). ### For Java, Spring Boot, cats-effect and ZIO ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-java-api" % llm4sVersion // Java facade libraryDependencies += "org.llm4s" %% "llm4s-java-rag" % llm4sVersion // Java facade for RAG (Experimental) libraryDependencies += "org.llm4s" %% "llm4s-reactor" % llm4sVersion // Reactor Flux over the Java facade (Experimental) libraryDependencies += "org.llm4s" %% "llm4s-spring-boot-starter" % llm4sVersion // Spring Boot auto-configuration libraryDependencies += "org.llm4s" %% "llm4s-effect" % llm4sVersion // cats-effect 3 / fs2 libraryDependencies += "org.llm4s" %% "llm4s-zio" % llm4sVersion // ZIO 2 / ZIO Streams libraryDependencies += "org.llm4s" %% "llm4s-pekko" % llm4sVersion // Apache Pekko Streams ``` `llm4s-java-api` is the Java-friendly facade (a runnable Gradle sample is in [`modules/samples/gradle-java`](https://github.com/llm4s/llm4s/tree/main/modules/samples/gradle-java), and a Quarkus application with a CDI producer in [`modules/samples/quarkus-java`](https://github.com/llm4s/llm4s/tree/main/modules/samples/quarkus-java), see [Quarkus](../guide/quarkus)) and the base of the Spring starter, which adds the `llm4s.*` properties, an `LLM4STemplate` and an Actuator health indicator. `llm4s-java-rag` puts `llm4s-rag`'s pipeline behind one Java class, `JRag` ([Java RAG](../guide/java-rag)); it is a separate artifact so that a Java facade user who only calls a model does not receive document parsing and the database drivers. `llm4s-reactor` turns a streamed reply into a Reactor `Flux` ([Java Reactor](../guide/java-reactor)), apart from the facade so that it does not bring `reactor-core` to an application without Reactor. `llm4s-effect`, `llm4s-zio` and `llm4s-pekko` wrap `LLMClient` and `Agent` for their effect and streaming systems. ### For Kotlin (coroutines and Flow) `llm4s-kotlin-api` is the Kotlin coroutine API over the Java facade: `LLMClientKt` and `AgentKt` with suspend functions, and `Flow`s of an agent turn's events. It is a Kotlin artifact, so it has **no** `_3` suffix, and it is released at the same version as every other llm4s module, so the BOM manages it too: > **`llm4s-kotlin-api` 0.5.1 is not on Maven Central yet.** A fault in the release > workflow left it out of the 0.5.0 and 0.5.1 releases; the fix is pending > ([#1976](https://github.com/llm4s/llm4s/pull/1976)). Until a release includes it, the coordinates below do not > resolve (the BOM lists it, but every other entry of the BOM resolves). Build it locally instead, as described > below the snippet; it then depends on the released `llm4s-java-api_3`. ```kotlin // build.gradle.kts dependencies { implementation(platform("org.llm4s:llm4s-bom:0.5.1")) implementation("org.llm4s:llm4s-kotlin-api") // brings llm4s-java-api_3 and kotlinx-coroutines-core } ``` Without the BOM, write the version on it: `implementation("org.llm4s:llm4s-kotlin-api:0.5.1")`. It runs on JDK 21, like the Java facade it wraps. The module is built by Gradle (`modules/kotlin-api`), not sbt, so `sbt publishLocal` does not build it. To build it, check out the release tag (`git checkout v0.5.1`) and run `gradle publishToMavenLocal -Pllm4sVersion=0.5.1` in `modules/kotlin-api`, then add `mavenLocal()` to your repositories; it depends on `llm4s-java-api_3` at that version, which is on Maven Central. Without `-Pllm4sVersion` it builds at `0.1.0-SNAPSHOT`; to try it from `main`, run `sbt publishM2` first and pass the version sbt published. Its entry point is `org.llm4s.kotlin.Llm4s`: `createDefaultClient()` builds an `LLMClientKt` from `application.conf`, and `wrapClient(JLlmClient)` wraps one configured in code with the Java facade's builders (see the Java guide's [Configure in code](../guide/java#configure-in-code)); `createAgent` and `wrapAgent` give an `AgentKt`. A Kotlin DSL declares tools (`tools { tool(...) { ... } }`), completion options (`completionOptions { ... }`) and clients (`Llm4s.openAi { ... }`); see the Java guide's [Kotlin DSL](../guide/java#the-kotlin-dsl). ### For image generation and vision ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-image" % llm4sVersion ``` Carries image generation (`org.llm4s.imagegeneration` — Stable Diffusion, Stability AI, Hugging Face, OpenAI) and image processing (`org.llm4s.imageprocessing` — the OpenAI and Anthropic vision clients plus a local `javax.imageio` processor). It adds no third-party dependency of its own, and brings `llm4s-media` with it. Package names are unchanged, so existing `org.llm4s.imagegeneration.*` and `org.llm4s.imageprocessing.*` imports keep working — but note that image *formats* did change, in `llm4s-media`. See the [migration note](../reference/migration#slice-3-llm4s-image). ### For media types (`MediaType`, `MediaCategory`) ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-media" % llm4sVersion ``` The shared vocabulary the multimodal modules speak: `MediaType` (MIME string, canonical extension, category, and lookups by extension, path or MIME type) and `MediaCategory`. It has no dependencies at all and does no I/O — deciding what a file is from its bytes needs Tika and lives in `llm4s-rag`. You will usually get it transitively, from `llm4s-core` or `llm4s-rag`; declare it directly only if you name these types in your own signatures. It replaces three overlapping image-format types that used to ship in `llm4s-core`, which is a source break — see the [migration note](../reference/migration#slice-3-llm4s-media). ### For agents (`Agent`, guardrails, handoffs, graphs) ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent" % llm4sVersion // same version as llm4s-core ``` The agent runtime - `org.llm4s.agent` (`Agent`, `AgentResult`, guardrails, handoffs, the graph runtime) and the console assistant, `org.llm4s.assistant` - lives here. Agent memory is the separate `llm4s-memory`; the ready-made tools are `llm4s-agent-tools`. `llm4s-core` keeps what the agent is built on: `LLMClient`, the tool API and the tracing contract. Package names are unchanged. See the [migration note](../reference/migration#slice-7-llm4s-agent---the-agent-runtime-leaves-core). ### For durable agent threads (SQLite checkpointer) ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent-checkpoint-sqlite" % llm4sVersion // same version as llm4s-agent ``` `SqliteCheckpointer` (`org.llm4s.agent.graph.sqlite`) keeps graph and agent threads - checkpoints, pending writes, event logs and run claims - in one SQLite file, so a run survives a restart and `recover` continues it in a new process. It brings the SQLite JDBC driver (`org.xerial:sqlite-jdbc`), which `llm4s-agent` itself does not depend on. See [Durable Checkpointers](../guide/agents/durable-checkpointers#the-sqlite-store). ### For agent threads shared across machines (PostgreSQL checkpointer) ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent-checkpoint-postgres" % llm4sVersion // same version as llm4s-agent ``` `PostgresCheckpointer` (`org.llm4s.agent.graph.postgres`) keeps the same threads in a PostgreSQL schema that runtimes on several machines share, with claims and retention judged by the database's clock. It brings the PostgreSQL JDBC driver and HikariCP. See [Durable Checkpointers](../guide/agents/durable-checkpointers#the-postgresql-store). ### For semantic memory in agents ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent-memory" % llm4sVersion // same version as llm4s-agent ``` `SemanticMemoryMiddleware` (`org.llm4s.agent.harness.memory`) recalls a tenant's memories before each turn and records the turn after it, on an `llm4s-memory` store. It depends on `llm4s-agent` and `llm4s-memory`. See [Semantic Memory Middleware](../guide/agents/memory#semantic-memory-middleware). ### For built-in tools (web search, HTTP, filesystem, shell) ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent-tools" % llm4sVersion // same version as llm4s-core ``` `BuiltinTools` and everything under `org.llm4s.toolapi.builtin` - DateTime, Calculator, UUID, JSON, file access, HTTP, shell, and the Brave, DuckDuckGo and Exa search tools - live here, with `ToolsConfigLoader` and the `llm4s.tools` settings the search tools read. `llm4s-core` keeps the tool API they implement (`ToolFunction`, `ToolRegistry`). Package names are unchanged. See the [migration note](../reference/migration#slice-7-llm4s-agent-tools---the-built-in-tools-leave-core). ### For tracing and metrics (Langfuse, OpenTelemetry, in-process trace store, Prometheus) ```scala // same version as llm4s-core libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-observability" % llm4sVersion, // TRACING_MODE=langfuse "org.llm4s" %% "llm4s-observability-otel" % llm4sVersion, // TRACING_MODE=opentelemetry "org.llm4s" %% "llm4s-observability-prometheus" % llm4sVersion, // llm4s.metrics.enabled = true "org.llm4s" %% "llm4s-observability-micrometer" % llm4sVersion // MicrometerMetrics over your MeterRegistry ) ``` `llm4s-core` builds only the `console` (default) and `noop` tracers. `llm4s-observability` registers Langfuse and carries the in-process trace collector and store (`TraceCollectorTracing`, `org.llm4s.trace.store`, `org.llm4s.trace.model`) and `CostTracker`, with no third-party dependency; `llm4s-observability-otel` registers OpenTelemetry and brings the OpenTelemetry SDK. Adding the dependency is all the registration there is, and the `LANGFUSE_*` / `OTEL_*` variables keep their names. `llm4s-observability-prometheus` carries `PrometheusMetrics`, the `/metrics` endpoint and `MetricsConfigLoader`, with the Prometheus client and HTTP server; core keeps only the `MetricsCollector` interface every client accepts. Package names are unchanged. See the migration notes for [Langfuse and the collector](../reference/migration#slice-6-llm4s-observability---langfuse-the-trace-collector-and-costtracker-leave-core) and for [Prometheus](../reference/migration#slice-6-llm4s-observability-prometheus---prometheus-leaves-core). `llm4s-observability-micrometer` is the Micrometer counterpart: a `MetricsCollector` that records into a `MeterRegistry` you already have, with the same series names as the Prometheus module. It depends on `llm4s-core` and `micrometer-core`. See [Micrometer metrics](../guide/observability/micrometer). ### For Neo4j (knowledge graph store) ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-knowledgegraph-neo4j" % llm4sVersion ``` `Neo4jGraphStore`, a graph store for `llm4s-knowledgegraph` backed by Neo4j. It depends on `llm4s-core` and `llm4s-knowledgegraph` and brings the Neo4j driver. ### Structured output from a case class ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-schema-derivation" % llm4sVersion ``` `SchemaOf` and `client.completeStructuredOf[A]`: the JSON schema of `completeStructured`, derived from a case class with `derives`. It is experimental, and depends on `llm4s-core` only. See [Derived Schemas](../guide/derived-schemas). ### Testing your own code (the testkit) ```scala // same version as llm4s-core; test scope only libraryDependencies += "org.llm4s" %% "llm4s-testkit" % llm4sVersion % Test ``` A scriptable fake `LLMClient` for the tests of code that calls llm4s: canned replies, scripted tool calls, injected errors and recorded requests, with no network and no API key. It depends on `llm4s-core` only. See [Testing with the Testkit](../guide/testing-with-the-testkit). ### For provider authors (testing a provider module) ```scala // same version as llm4s-core; test scope only libraryDependencies += "org.llm4s" %% "llm4s-provider-testkit" % llm4sVersion % Test ``` The checks a provider module's own `Llm4sModuleSpec` makes: discovery, sole ownership of its ids, explicit registration and the config-to-client round trip. It requires JDK 21: its interruption checks and its local test server run on virtual threads. See [Writing a provider](../guide/writing-a-provider#testing). ### For checkpoint store authors (testing a `Checkpointer`) ```scala // same version as llm4s-agent; test scope only libraryDependencies += "org.llm4s" %% "llm4s-agent-testkit" % llm4sVersion % Test ``` `CheckpointerContract`, the suite every `Checkpointer` must pass: commits, event numbering, compaction, run-claim leases and fencing, and two runtimes contending over one store. It requires JDK 21, as `llm4s-agent` does. See [Durable Checkpointers](../guide/agents/durable-checkpointers#testing-it-with-the-contract-suite). ### For Workspace (Containerized Execution) ```scala libraryDependencies += "org.llm4s" %% "llm4s-workspace-client" % "0.5.1" ``` And install Docker: ```bash # macOS brew install docker # Ubuntu/Debian sudo apt-get install docker.io # Verify docker --version ``` `llm4s-workspace-client` brings `llm4s-workspace-shared`, the wire protocol the client and the runner image speak, so you depend on it directly only if you implement the runner side. --- ## Provider Setup llm4s reads provider configuration from named sections in your `application.conf`, not from environment variables of its own. Keep secrets in environment variables and bind them into the section with `${?VAR}`: ```hocon # src/main/resources/application.conf llm4s { providers { provider = "openai-main" # the default: the name of a section below openai-main { provider = "openai" model = "gpt-4o-mini" } } } ``` ```bash export OPENAI_API_KEY=sk-... ``` The section needs no `apiKey` line: `llm4s-openai` binds `OPENAI_API_KEY` to OpenAI's shared key, which a section without a key of its own uses (see [API keys](configuration#api-keys) for a second account). Nothing reads `LLM_MODEL`. The [Configuration guide](configuration#named-provider-sections) shows sections for every provider, how to [switch between them](configuration#switching-providers), and which [environment variables llm4s reads](configuration#environment-variables-llm4s-reads) without any binding. If you keep your variables in a `.env` file (add it to `.gitignore`!), load it before running: ```bash source .env sbt run ``` ### Get API Keys #### OpenAI 1. Go to [platform.openai.com](https://platform.openai.com) 2. Sign up or log in 3. Navigate to **API Keys** 4. Click **Create new secret key** 5. Copy the key (starts with `sk-`) #### Anthropic 1. Go to [console.anthropic.com](https://console.anthropic.com) 2. Sign up or log in 3. Navigate to **API Keys** 4. Click **Create Key** 5. Copy the key (starts with `sk-ant-`) #### Azure OpenAI 1. Create an Azure account 2. Navigate to **Azure OpenAI Service** 3. Create a resource 4. Deploy a model (e.g., gpt-4o) 5. Copy the **API Key** and **Endpoint** #### Ollama (Local) 1. Install Ollama: [ollama.com](https://ollama.com) 2. Pull a model: `ollama pull llama2` 3. Start server: `ollama serve` 4. No API key needed! --- ## Verify Installation Create a simple test file `VerifyInstall.scala`: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model.{ Conversation, UserMessage } import org.llm4s.model.ModelRegistryService object VerifyInstall extends App { println("Testing LLM4S installation...") val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) response <- client.complete(Conversation(Seq(UserMessage("Say 'LLM4S is working!'")))) } yield response result match { case Right(completion) => println("✅ Success!") println(s"Response: ${completion.content}") case Left(error) => println("❌ Error:") println(error) } } ``` Run it: ```bash sbt run ``` Expected output: ``` Testing LLM4S installation... ✅ Success! Response: LLM4S is working! ``` --- ## Troubleshooting ### "missing required fields: apiKey" **Problem**: the provider section has no API key. **Solution**: 1. Check the vendor's variable is set in the shell that starts the JVM: `echo $OPENAI_API_KEY` (if you use a `.env` file, `source .env` first), and that the provider's module (`llm4s-openai`) is a dependency - it is what binds the variable 2. If the section sets its own `apiKey = ${?SOME_VAR}`, check that variable too 3. Check the error names the section you meant to load - only that section is validated, so if it names another, `llm4s.providers.provider` points somewhere you did not expect ### "Configured provider '...' was not found" or an unknown provider id **Problem**: `llm4s.providers.provider` names a section that does not exist, or a section's `provider = "..."` id is not registered. **Solution**: make `llm4s.providers.provider` match a section name exactly, and add the module for the provider id (for example `llm4s-openai` for `provider = "openai"`; see [Optional Dependencies](#optional-dependencies)). Model names go in the section's `model` without a provider prefix: `model = "gpt-4o"` for OpenAI, not `openai/gpt-4o`. ### Compilation Errors **Problem**: Scala version mismatch. **Solution**: ```bash # Clean and recompile sbt clean sbt compile ``` ### Dependency Resolution Issues **Problem**: Can't resolve LLM4S dependency. **Solution**: - Releases are on Maven Central, so no additional resolver is needed - With Maven or Gradle, write the Scala suffix yourself: `llm4s-core_3`, not `llm4s-core` (the BOM, `llm4s-bom`, has none) - No snapshots are published: to use `main`, build it locally (see [Unreleased changes](#unreleased-changes-main)) - `llm4s-kotlin-api` is not on Maven Central for 0.5.1 yet (see [For Kotlin](#for-kotlin-coroutines-and-flow)) --- ## Next Steps Now that LLM4S is installed: 1. **[Write your first program →](first-example)** - Create a simple LLM application 2. **[Configure providers →](configuration)** - Set up multiple LLM providers 3. **[Explore examples →](/examples/)** - Browse 69 working examples --- ## Additional Resources - **GitHub Repository**: [llm4s/llm4s](https://github.com/llm4s/llm4s) - **Starter Kit**: [llm4s.g8](https://github.com/llm4s/llm4s.g8) - **Discord Community**: [Join us](https://discord.gg/4uvTPn6qww) - **API Reference**: [Core API](/api/) --- **Installation complete!** Ready to [write your first program →](first-example) --- Source: https://llm4s.org/getting-started/first-example # Your First LLM4S Program Build your first LLM-powered application in minutes. --- ## 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](installation)), to `build.sbt`: ```scala 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`: ```hocon llm4s { providers { provider = "openai-main" # the default: the name of a section below openai-main { provider = "openai" model = "gpt-4o-mini" } } } ``` The [Configuration guide](configuration#named-provider-sections) has sections for Anthropic, Gemini, Azure and Ollama; the program below does not change when you switch. ```scala 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 ```bash # llm4s-openai binds OPENAI_API_KEY to OpenAI's shared key, which the section uses export OPENAI_API_KEY=sk-... sbt run ``` ### Expected Output ``` 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 ```scala 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.provider` names in `application.conf` - Selects the appropriate provider (OpenAI, Anthropic, etc.) - Injects the `LLMClient` into your core logic (`HelloLLM`). ### 2. Complete with Messages ```scala 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 ```scala result match { case Right(completion) => // Success case Left(error) => // Error } ``` LLM4S uses **Result types** instead of exceptions: - `Right(value)` = success - `Left(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: ```scala 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: ```scala 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: 1. Understand you're asking about weather 2. Call the `get_weather` tool with "Paris" 3. Use the tool result to formulate a response --- ## Streaming Responses For real-time output, use streaming: ```scala 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 ```scala 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 ```scala result.fold( error => println(s"Failed: $error"), completion => println(s"Success: ${completion.content}") ) ``` ### Mapping Results ```scala val content: Result[String] = result.map(_.content) val uppercased: Result[String] = content.map(_.toUpperCase) ``` --- ## Configuration Options ### Using Different Models ```scala 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`: ```hocon openai-main { provider = "openai" model = "gpt-4o" } ``` ```scala client.complete(conversation, CompletionOptions(temperature = 0.7, maxTokens = Some(1000))) ``` --- ## Complete Working Example Here's a complete example combining everything we've learned: ```scala 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 ```scala // Assuming client is injected val response = client.complete( Conversation(Seq(UserMessage("Your question here"))) ) ``` ### Pattern 2: With System Prompt ```scala // Assuming client is injected val response = client.complete( Conversation(Seq( SystemMessage("You are an expert in..."), UserMessage("Question") )) ) ``` ### Pattern 3: Agent with Tools ```scala // 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 ```scala // 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: ```scala // 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: ```scala 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: ```scala 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: ```hocon llm4s.providers.my-openai { provider = "openai" model = "gpt-4o" timeouts { request = 3m, stream = 15m } } ``` See [Timeouts](configuration#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](configuration#switching-providers)): ```hocon 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: ```scala 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: ```scala 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: 1. **[Configuration →](configuration)** - Learn about advanced configuration 2. **[Agent Framework →](/examples/#agent-examples)** - Build sophisticated agents 3. **[Tool Calling →](/examples/#tool-examples)** - Create custom tools 4. **[Examples →](/examples/)** - Browse 69 working examples --- ## Learning Resources - **[Basic Examples](/examples/basic)** - More beginner-friendly examples - **[User Guide](/guide/)** - Comprehensive feature guide - **[API Reference](/api/)** - Detailed API documentation - **[Discord Community](https://discord.gg/4uvTPn6qww)** - Get help and share ideas --- **Ready to dive deeper?** [Configure your environment →](configuration) --- Source: https://llm4s.org/getting-started/configuration # Configuration Guide Configure LLM4S for multiple providers, environments, and use cases. --- ## How configuration is loaded `Llm4sConfig` reads [Typesafe Config](https://github.com/lightbend/config) through PureConfig, with this precedence (highest first): 1. **JVM system properties** - `-Dllm4s.providers.provider=claude` 2. **Your `application.conf`** - `src/main/resources/application.conf`, or a file named with `-Dconfig.file=/path/to/app.conf` (or `-Dconfig.resource=prod.conf` for another resource) 3. **`reference.conf`** - the defaults shipped in each llm4s module on the classpath Environment variables are **not** a layer of their own. llm4s reads one only where a `${?VAR}` substitution names it: in a module's `reference.conf` (tracing, embeddings, tools, and each provider module's vendor API key - see [Environment variables llm4s reads](#environment-variables-llm4s-reads)), or in your own `application.conf`. Each provider module binds its vendor's conventional key variable - `llm4s-openai` binds `OPENAI_API_KEY`, `llm4s-anthropic` binds `ANTHROPIC_API_KEY` - to a shared key under `llm4s.credentials` (see [API keys](#api-keys)). Nothing reads `LLM_MODEL`. `${?VAR}` means "the value of `VAR` if it is set, otherwise leave the key unset". A second line for the same key overrides the first only when the variable is set, which gives you a default with an environment override: ```hocon model = "gpt-4o-mini" model = ${?OPENAI_MODEL} # used only when OPENAI_MODEL is set ``` --- ## Named provider sections Chat providers are configured as named sections under `llm4s.providers`. You choose each section's name; `provider` inside it says which provider it is, and `llm4s.providers.provider` names the section `Llm4sConfig.defaultProvider()` loads. **1. Add llm4s and the module for your provider:** ```scala // build.sbt libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-core" % llm4sVersion, "org.llm4s" %% "llm4s-openai" % llm4sVersion // the module for your provider ) ``` | `provider = ...` | Module | |---|---| | `openai`, `azure`, `requesty` | `llm4s-openai` | | `anthropic` | `llm4s-anthropic` | | `gemini`, `vertexai` | `llm4s-gemini` | | `ollama` | `llm4s-ollama` | | `bedrock` | `llm4s-bedrock` | | `deepseek`, `zai`, `openrouter`, `mistral`, `cohere`, `xai`, `openai-compatible` | `llm4s-openai-compatible` | Before 0.5.0, every provider shipped inside `llm4s-core`, so the `llm4s-core` dependency alone was enough. **2. Add a section to `src/main/resources/application.conf`:** ```hocon llm4s { providers { provider = "openai-main" # the default: the name of a section below openai-main { provider = "openai" model = "gpt-4o-mini" } } } ``` **3. Set your vendor's API-key variable:** ```bash export OPENAI_API_KEY=sk-... ``` The section needs no `apiKey` line: `llm4s-openai` binds `OPENAI_API_KEY` to OpenAI's shared key, `llm4s.credentials.openai.apiKey`, and a section without a key of its own uses that one. See [API keys](#api-keys) for a second account or a variable of your own. **4. Load it at the edge of your application:** ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model.{ Conversation, UserMessage } import org.llm4s.model.ModelRegistryService val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) response <- client.complete(Conversation(Seq(UserMessage("What is Scala?")))) } yield response.content ``` If `OPENAI_API_KEY` is unset, `defaultProvider()` returns a `ConfigurationError` naming both places a key can come from: `apiKey: set OPENAI_API_KEY, or set apiKey under llm4s.providers.openai-main in application.conf`. This exact configuration is exercised by `DocumentedProviderConfigSpec` in `modules/openai`. Other `Llm4sConfig` calls read the same sections: ```scala Llm4sConfig.provider("openai-main") // any section, by name Llm4sConfig.defaultProviderName() // the value of llm4s.providers.provider Llm4sConfig.providers() // every section, validated - fails if any one is invalid Llm4sConfig.providerConfigs() // every section, each loaded or failed on its own Llm4sConfig.listModels() // model discovery for the default section Llm4sConfig.listModels("openai-main") // ... or for a named one ``` ### API keys Credentials belong to a vendor; sections belong to a use. A client's key is, in order: 1. its own `apiKey` - in its chat section `llm4s.providers.`, its embeddings block `llm4s.embeddings.` or the reranker block `llm4s.rerank.cohere`; 2. otherwise its vendor's shared key, `llm4s.credentials..apiKey`, which the provider module binds to the vendor's conventional variable; 3. otherwise a `ConfigurationError` naming both. An alias resolves to its canonical provider's key: a `provider = "google"` section uses `llm4s.credentials.gemini`. When a key is resolved, llm4s logs at INFO where it came from - for example `llm4s.providers.openai-main: API key from llm4s.credentials.openai.apiKey` - and never the value. | Provider id | Shared key bound to | Module | |---|---|---| | `openai` | `OPENAI_API_KEY` | `llm4s-openai` | | `azure` | `AZURE_OPENAI_API_KEY` | `llm4s-openai` | | `requesty` | `REQUESTY_API_KEY` | `llm4s-openai` | | `anthropic` | `ANTHROPIC_API_KEY` | `llm4s-anthropic` | | `gemini` (alias `google`) | `GOOGLE_API_KEY`, else `GEMINI_API_KEY` | `llm4s-gemini` | | `deepseek` | `DEEPSEEK_API_KEY` | `llm4s-openai-compatible` | | `zai` | `ZAI_API_KEY` | `llm4s-openai-compatible` | | `openrouter` | `OPENROUTER_API_KEY` | `llm4s-openai-compatible` | | `mistral` | `MISTRAL_API_KEY` | `llm4s-openai-compatible` | | `xai` | `XAI_API_KEY` | `llm4s-openai-compatible` | | `cohere` | `COHERE_API_KEY` (chat, reranker and embeddings) | `llm4s-openai-compatible`, `llm4s-rag`, `llm4s-cohere` | | `voyage` | `VOYAGE_API_KEY` | `llm4s-voyage` | | `jina` | `JINA_API_KEY` | `llm4s-jina` | | `typesafe` | `TYPESAFE_API_KEY` (the Jev decision client; not a chat or embeddings provider) | `llm4s-jev` | `ollama` takes no key, the generic `openai-compatible` provider has no vendor, `vertexai` authenticates with OAuth2 (Application Default Credentials, or a service-account file named by its `apiKey`), and `bedrock` authenticates with AWS credentials (the AWS default credential chain, a `profile`, or explicit keys), so none of them has a shared key. A section for a **second account** sets its own key, which wins over the shared one: ```hocon llm4s.providers { openai-main { # uses OPENAI_API_KEY provider = "openai" model = "gpt-4o-mini" } openai-batch { # billed to another account provider = "openai" model = "gpt-4o-mini" apiKey = ${?OPENAI_BATCH_API_KEY} } } ``` The same form reads a variable of your own choosing - `apiKey = ${?MY_TEAM_OPENAI_KEY}`. Note that if `OPENAI_BATCH_API_KEY` is unset, `openai-batch` falls back to the shared `OPENAI_API_KEY` and bills the default account. In production, give every section its own `apiKey`; the config-policy `prod` preset flags any section that does not (see [Production deployment](../PRODUCTION_DEPLOYMENT)). ### Workload identity (SPIFFE) A section can authenticate with a workload identity token - typically a SPIFFE JWT-SVID that [`spiffe-helper`](https://github.com/spiffe/spiffe-helper) keeps fresh in a file - instead of an API key. Put an `auth` block in the section, with `identityTokenFile` (re-read on every exchange, so rotation is picked up) and the keys the provider needs. A relative `identityTokenFile` is resolved against the process's working directory when the configuration is loaded, so prefer an absolute path. The path must name a regular file of at most 64 KiB (a symbolic link to one is followed, as Kubernetes projected volumes rotate tokens that way); anything else fails the exchange with an `AuthenticationError` that names the path but never quotes the file. For `openai-compatible` and `openai` llm4s reads the file itself; for `anthropic` the Anthropic SDK reads it. Keep the file readable only by the workload where the platform allows it. A section sets `apiKey` or `auth`, never both, and a section with `auth` may not set an `Authorization` header either: the exchanged token is the request's bearer. With `auth`, the shared `llm4s.credentials..apiKey` is not used. **Databricks model serving** (the generic `openai-compatible` provider; the SVID is exchanged at the workspace's RFC 8693 endpoint, the token cached until shortly before it expires, and refreshed once if a request is rejected with 401): ```hocon databricks-main { provider = "openai-compatible" baseUrl = "https://.cloud.databricks.com/serving-endpoints" model = "" auth { identityTokenFile = "/var/run/secrets/spiffe/databricks" tokenUrl = "https://.cloud.databricks.com/oidc/v1/token" clientId = ${?DATABRICKS_CLIENT_ID} # the service principal, for a service-principal federation policy scope = "all-apis" } } ``` The optional `audience` key sets the RFC 8693 `audience` parameter, for a token endpoint that needs one. `tokenUrl` and `baseUrl` must be `https` (plain `http` only for a loopback host). The exchanged token is sent as the bearer of every request to `baseUrl`, a host you choose, so no host is allow-listed: make sure `tokenUrl` and `baseUrl` belong to the same trusted service. The token is cached for the reply's `expires_in`; a reply without one is taken to live until the access token's `exp` claim if it is a JWT, and otherwise for 5 minutes. **OpenAI** (the OpenAI SDK's workload identity federation): ```hocon openai-wif { provider = "openai" model = "gpt-4o-mini" auth { identityTokenFile = "/var/run/secrets/spiffe/openai" identityProviderId = ${?OPENAI_IDENTITY_PROVIDER_ID} serviceAccountId = ${?OPENAI_SERVICE_ACCOUNT_ID} } } ``` **Anthropic** (the Anthropic SDK's workload identity federation; `identityTokenFile` only, not a literal `identityToken`): ```hocon anthropic-wif { provider = "anthropic" model = "claude-sonnet-4-5" auth { identityTokenFile = "/var/run/secrets/spiffe/anthropic" federationRuleId = ${?ANTHROPIC_FEDERATION_RULE_ID} organizationId = ${?ANTHROPIC_ORGANIZATION_ID} serviceAccountId = ${?ANTHROPIC_SERVICE_ACCOUNT_ID} workspaceId = ${?ANTHROPIC_WORKSPACE_ID} } } ``` A `${?VAR}` that is unset leaves its key out, and a missing required key is reported when the section is loaded. With `auth`, `baseUrl` must be the vendor's own API host over `https`, because the exchanged token is a vendor credential sent with every request (plain `http` is accepted only for a loopback host, for tests): for OpenAI `api.openai.com` or a data-residency host (`us.`, `eu.` or `ae.api.openai.com`), for Anthropic `api.anthropic.com`, where the SDK also posts the identity token (`/v1/oauth/token`). A proxy or gateway `baseUrl` needs an `apiKey` instead. The Anthropic SDK reads `identityTokenFile` itself, so llm4s's own checks on a token file it reads (a regular file of at most 64 KiB, read with a bounded read) do not guard the SDK's reads. Building the client applies them once: a path that is a directory, a FIFO, a device or a file larger than 64 KiB is refused then, as a `ConfigurationError` naming `workloadIdentity.identityTokenFile`. A path that does not exist yet is accepted, because an identity agent may write it after the application starts. Point it at the file the agent keeps fresh. Request each SVID for the audience its relying party expects (`jwt_audience` in `spiffe-helper`). Other providers reject an `auth` block. The `prod` config-policy preset's `ownApiKey` rule accepts a section that authenticates this way. Model listing works for an `openai-compatible` section with `auth` (it exchanges once). For `openai` and `anthropic` sections with `auth` it is not supported - their SDKs perform the exchange only inside a client - and the lister returns a `ConfigurationError` saying so; list models from a section with an `apiKey`. ### Section keys | Key | Meaning | |---|---| | `provider` | Required. The provider id, e.g. `openai`, `anthropic`, `ollama` (table above) | | `model` | Required. The model name as the provider spells it, e.g. `gpt-4o-mini` | | `apiKey` | The API key; required by every cloud provider. Optional in the section when the vendor's shared key is set ([API keys](#api-keys)) | | `baseUrl` | Overrides the provider's default endpoint (`ollama`: `http://localhost:11434`); **required** for `openai-compatible`. Checked when the section is loaded: an absolute `http` or `https` URL with a host, or a `ConfigurationError` naming the key (a blank value counts as unset) | | `headers` | Extra HTTP headers; sent by generic `openai-compatible` endpoints and by model listing | | `timeouts` | How long a request and a stream may take: `timeouts { request = 3m, stream = 15m }`. Every provider that makes HTTP calls reads it; see [Timeouts](#timeouts) | | `organization` | OpenAI, Requesty and OpenRouter: the OpenAI organisation id, sent as `OpenAI-Organization` | | `endpoint`, `apiVersion` | Azure OpenAI: the resource endpoint (required) and API version | | `project`, `location` | Vertex AI: the GCP project id (required) and region (default `us-central1`) | | `region`, `profile`, `accessKeyId`, `secretAccessKey`, `sessionToken` | AWS Bedrock: the AWS region (required, never defaulted); a shared-config profile; or explicit credentials, with a session token for temporary ones. With none of the credential keys the AWS default credential chain is used. `baseUrl` overrides the endpoint | | `contextWindow`, `reserveCompletion`, `registryProvider`, `streamUsage` | Generic `openai-compatible` endpoints ([details](../guide/providers#openai-compatible-endpoints)) | The first six keys are shared by every provider. The rest belong to the providers named, which declare them ([provider-specific keys](../guide/providers#provider-specific-keys)); in a section for any other provider such a key is ignored with a warning naming it, as a misspelt key is. Each provider module's `reference.conf` has a commented example section, and the [provider guide](../guide/providers) covers each provider in detail. ### Timeouts Every provider section, and every embedding section, accepts an optional `timeouts` block: ```hocon llm4s.providers.my-openai { provider = "openai" model = "gpt-4o" timeouts { request = 3m # a call that returns one response: a completion, an embedding stream = 15m # a streamed completion } } llm4s.embeddings.openai.timeouts.request = 30s ``` Each value is a duration (`30s`, `2m`, `1500ms`, `1h`) and is optional: **a value you leave out keeps that client's own default, so a section without the block behaves exactly as it did before.** A value must be positive and finite; `0s`, a negative value, `Inf` and anything that is not a duration are refused when the section is loaded, naming the key (`llm4s.providers.my-openai.timeouts.request`). A misspelt key inside the block (`reqest = 3m`) is refused too, rather than silently leaving the default in force. In an embedding section only `request` has a meaning, since an embedding call does not stream. The usual precedence applies: `-D` system properties, then `application.conf`, then a module's `reference.conf`; to take a value from the environment, bind it yourself with `timeouts.request = ${?MY_REQUEST_TIMEOUT}`, which leaves the default in force while the variable is unset. | Provider | `request` default | `stream` default | What the timeout bounds | |---|---|---|---| | `openai-compatible`, `deepseek`, `zai`, `openrouter`, `mistral`, `cohere` (chat) | 2 minutes | 5 minutes | the wait for the response to begin | | `gemini`, `vertexai`, `ollama`, `watsonx` (chat) | 2 minutes | 10 minutes | the wait for the response to begin | | `openai`, `azure`, `requesty`, `anthropic` | the SDK's own default | the SDK's own default | the whole call, including a streamed body | | `bedrock` | the AWS SDK's own default | no limit | the whole call, including retries and a streamed body | | Embeddings: `openai`, `ollama` | 2 minutes | n/a | the wait for the response to begin | | Embeddings: `voyage`, `jina`, `cohere` | 2 minutes | n/a | the wait for the response to begin | Five things to know: - **The HTTP-based clients bound the wait for the response to begin**, not the time a stream may then run. A stream whose server has begun answering is not cut by `stream`. - **The OpenAI, Azure, Requesty and Anthropic clients use their vendor's SDK**, which bounds the whole call and retries a call that fails or times out **twice** by default (llm4s does not change that). A `request = 30s` there can therefore take up to three attempts and their backoff before the call fails. `request` and `stream` are independent: a short `request` does not cut a stream. - **Bedrock uses the AWS SDK.** `request` becomes the SDK's API-call timeout, which covers its retries, so a `request = 30s` call fails within 30 seconds; `stream` is a deadline on the whole `ConverseStream` call, so unlike the HTTP-based clients it does cut a stream that is still running. Either expiry is a `TimeoutError`. - **The workload identity token exchange of an `openai-compatible` section with `auth`** (see [Workload identity](#workload-identity-spiffe)) follows that section's `timeouts.request`, so a slow token endpoint is given up on when a slow completion would be; with `request` unset it uses the request default, 2 minutes. - **Model listing, the Vertex AI token request and the watsonx IAM token exchange keep their own fixed timeouts**, which the block does not change. That includes the exchange model listing makes for an `openai-compatible` section with `auth`. ### Examples for other providers ```hocon llm4s { providers { provider = "claude" claude { # key from ANTHROPIC_API_KEY provider = "anthropic" model = "claude-sonnet-4-20250514" } gemini-main { # key from GOOGLE_API_KEY or GEMINI_API_KEY provider = "gemini" model = "gemini-2.0-flash" } azure-main { # key from AZURE_OPENAI_API_KEY provider = "azure" model = "gpt-4o" endpoint = ${?AZURE_API_BASE} # https://.openai.azure.com apiVersion = ${?AZURE_API_VERSION} # optional } ollama-local { provider = "ollama" model = "llama3.2" baseUrl = ${?OLLAMA_BASE_URL} # optional override; the default is http://localhost:11434 } } } ``` **Only the section you load is validated.** A section with no key available, or whose provider module is not on the classpath, fails when it is loaded - by `provider("")`, or by `defaultProvider()` when it is the default - and not otherwise. With the example above and `ANTHROPIC_API_KEY` alone set, `defaultProvider()` loads `claude` while `provider("gemini-main")` fails with a `ConfigurationError` naming `GOOGLE_API_KEY`, `GEMINI_API_KEY` and `gemini-main`'s `apiKey`. The exception is `Llm4sConfig.providers()`, which returns every section and so validates them all; `Llm4sConfig.providerConfigs()` instead reports each section's error separately. (Up to 0.4.1 every section was validated on every load.) --- ## Corporate networks: a proxy and a custom CA Behind an outbound proxy, or behind TLS inspection that re-signs traffic with a corporate CA, the first call fails with `connection timed out` or `PKIX path building failed`. One `llm4s.http` block fixes both for every provider client: the JDK client in core (the OpenAI-compatible providers, Ollama, Gemini, Vertex AI, watsonx.ai, the embedding providers and the model listers), the `openai-java` SDK (OpenAI, Azure OpenAI), the `anthropic-java` SDK and the AWS SDK (Bedrock) ([#2273](https://github.com/llm4s/llm4s/issues/2273)). ```hocon llm4s.http { proxy { url = "http://proxy.corp:3128" # bound to HTTPS_PROXY; an http proxy, with its port username = ${?LLM4S_PROXY_USER} # Basic proxy authentication, if the proxy asks for it password = ${?LLM4S_PROXY_PASSWORD} nonProxyHosts = ["localhost", "127.*", "*.internal"] # bound to NO_PROXY; reached directly } tls { trustStore = "/etc/pki/corp-ca.p12" # PKCS12 or JKS; trusted in addition to the JDK's CAs trustStorePassword = ${?LLM4S_TRUSTSTORE_PASSWORD} keyStore = "/etc/pki/client.p12" # optional: a client certificate, for mTLS gateways keyStorePassword = ${?LLM4S_KEYSTORE_PASSWORD} } } ``` - Core's `reference.conf` binds `HTTPS_PROXY`, `NO_PROXY`, `LLM4S_PROXY_USER`, `LLM4S_PROXY_PASSWORD`, `LLM4S_TRUSTSTORE`, `LLM4S_TRUSTSTORE_PASSWORD`, `LLM4S_KEYSTORE` and `LLM4S_KEYSTORE_PASSWORD`, so in a container the variables alone are enough. A `user:password@` in the proxy URL is read as the credentials too. - `nonProxyHosts` entries are a host (`ollama.local`), a domain suffix (`*.internal` or `.internal`), a prefix (`10.*`) or `*`. The default is the loopback addresses, so a local Ollama is reached directly; setting the list replaces that default. CIDR ranges are not supported. - The trust store **adds** CAs: the public CAs of the JDK stay trusted. - An unknown key, a proxy URL that is not `http://host:port`, or a store that cannot be opened (missing, corrupt, wrong password) fails the load with a `ConfigurationError` naming the key. Passwords never appear in an error, a log line or a `toString`. A section that needs a different route - a gateway reached through another proxy, or directly - sets its own `http` block, whose keys take precedence over `llm4s.http` one by one: ```hocon llm4s.providers.internal-gateway { provider = "openai-compatible" model = "llama-3-70b" baseUrl = "https://llm.internal.corp/v1" http.proxy.url = "" # no proxy for this section } ``` An HTTP client of your own - a tool, a vector store, an MCP transport - takes the same settings through `Llm4sHttpClient.create`: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.http.Llm4sHttpClient val routedClient = Llm4sConfig.httpNetwork().map(network => Llm4sHttpClient.create(network)) ``` Java builders take the same settings with `proxy`, `proxyCredentials`, `nonProxyHosts`, `trustStore` and `keyStore` (see [the Java guide](../guide/java#behind-a-proxy-or-a-corporate-ca)); the Spring Boot starter binds the `llm4s.http.*` properties (`llm4s.http.proxy.url`, `llm4s.http.proxy.non-proxy-hosts`, `llm4s.http.tls.trust-store`, ...). What is not covered: - **PAC files and Kerberos/NTLM proxy authentication.** Configure the JVM instead: `-Djava.net.useSystemProxies=true` for the operating system's proxy settings, and the JDK's `jdk.http.auth.*` properties for the schemes it supports. - **Basic proxy authentication over an `https` tunnel in the JDK client.** The JDK disables Basic for `CONNECT` by default; start the JVM with `-Djdk.http.auth.tunneling.disabledSchemes=""` to allow it. The `openai-java`, `anthropic-java` and AWS SDK clients authenticate the tunnel without it. - **Bedrock's credential lookups** (instance metadata, STS) use the AWS SDK's own clients and are not routed through `llm4s.http`; the AWS SDK reads `HTTPS_PROXY` for those itself. - **Clients you build yourself** with `Llm4sHttpClient.create()` keep the JVM's defaults; pass the settings as above. --- ## Switching providers Your code calls `Llm4sConfig.defaultProvider()` and does not change. To use a different provider, point `llm4s.providers.provider` at another section: - **Edit the file**: `provider = "claude"`. - **Bind it to a variable of your own**: ```hocon llm4s.providers { provider = "openai-main" provider = ${?LLM4S_PROVIDER} # your binding; any variable name works } ``` then `export LLM4S_PROVIDER=claude`. - **Override it for one run**: `sbt -Dllm4s.providers.provider=claude run`, or `java -Dllm4s.providers.provider=claude -jar app.jar`. - **Load a section by name** instead of the default: `Llm4sConfig.provider("claude")`. This is how an application uses several providers at once. For per-environment setups (local Ollama in development, a cloud provider in production), keep one file per environment - `application.conf` plus `prod.conf` that starts with `include "application.conf"` - and pick one with `-Dconfig.resource=prod.conf`. This is a matter of taste, not a requirement: a section an environment has no key for does no harm there unless it is loaded. --- ## Using a config your application already has `Llm4sConfig`'s methods read the process-wide `application.conf`. When your application already holds its configuration as a Typesafe `Config` - Play's `Configuration.underlying`, a Pekko `ActorSystem.settings.config`, one config per tenant, or one parsed per test case - pass it to `Llm4sConfig.from`. It returns an `Llm4sSettings` with the same methods (`defaultProvider()`, `provider(name)`, `providers()`, `embeddings()`, `tracing()`, `modelRegistryService()`, ...): ```scala import com.typesafe.config.{ Config, ConfigFactory } import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.{ LLMClient, LLMConnect } import org.llm4s.types.Result // In a Play or Pekko app this is the Config the framework already loaded. val appConfig: Config = ConfigFactory.parseString( """llm4s.providers { | provider = "openai-main" | openai-main { provider = "openai", model = "gpt-4o-mini" } |}""".stripMargin ) val llmClient: Result[LLMClient] = for { settings <- Llm4sConfig.from(appConfig) registry <- settings.modelRegistryService() cfg <- settings.defaultProvider() client <- LLMConnect.getClient(cfg)(using registry) } yield client ``` The given config takes the place of `application.conf` and nothing else changes, exactly as `ConfigFactory.load(config)` would: JVM system properties still override it, every llm4s module's `reference.conf` still sits beneath it - so the shared `llm4s.credentials..apiKey` fallback (`OPENAI_API_KEY` above) and each module's defaults apply - and `${?VAR}` substitutions are resolved against the process environment. A config that is already loaded and resolved is accepted as is. Two `Llm4sSettings` in one JVM never see each other's configuration. `from` returns a `Left(ConfigurationError)` when the config cannot be resolved, such as a `${VAR}` substitution with no value. `Llm4sConfig.from` and `Llm4sSettings` are `@Experimental`: their shape may still change before 1.0. --- ## Running the samples The repository's samples read `modules/samples/src/main/resources/application.conf`, which defines one section, `ollama-local`, as the default, and binds three variables of its own: | Variable | Bound by the samples' `application.conf` to | |---|---| | `LLM4S_PROVIDER` | `llm4s.providers.provider` - which section to use | | `OLLAMA_MODEL` | `llm4s.providers.ollama-local.model` (default `llama3:latest`) | | `OLLAMA_BASE_URL` | `llm4s.providers.ollama-local.baseUrl` (default `http://localhost:11434`) | These are the samples' bindings, not the library's: your own application reads them only if its `application.conf` binds them. With the default section, the model must be in your Ollama install: run `ollama pull llama3` once, or name a model you already have (`ollama list`) with `export OLLAMA_MODEL=`. To run a sample against another provider, add a section to `modules/samples/src/main/resources/application.local.conf` - git-ignored and included by the samples' `application.conf` - and select it: ```hocon # modules/samples/src/main/resources/application.local.conf llm4s.providers { openai-main { provider = "openai" model = "gpt-4o-mini" } } ``` ```bash export OPENAI_API_KEY=sk-... export LLM4S_PROVIDER=openai-main sbt "samples/runMain org.llm4s.samples.basic.BasicLLMCallingExample" ``` The chat-tui sample is the exception: `ChatTuiConfig` reads `LLM_MODEL=/` and the matching API-key variable itself, and falls back to `Llm4sConfig.defaultProvider()` when `LLM_MODEL` is unset. --- ## Embeddings Configuration LLM4S supports multiple embedding providers for RAG (Retrieval-Augmented Generation) workflows. ### Unified Format (Recommended) Embeddings are configured separately from chat providers, under `llm4s.embeddings`. Select the provider and model with `llm4s.embeddings.model` in `provider/model-name` form, which core's `reference.conf` binds to `EMBEDDING_MODEL`: ```bash # OpenAI embeddings (the same OPENAI_API_KEY the chat sections use) EMBEDDING_MODEL=openai/text-embedding-3-small OPENAI_API_KEY=sk-... # Voyage AI embeddings EMBEDDING_MODEL=voyage/voyage-3 VOYAGE_API_KEY=pa-... # Jina AI embeddings EMBEDDING_MODEL=jina/jina-embeddings-v3 JINA_API_KEY=jina_... # Cohere embeddings EMBEDDING_MODEL=cohere/embed-english-v3.0 COHERE_API_KEY=... # Ollama embeddings (local, no API key needed) EMBEDDING_MODEL=ollama/nomic-embed-text ``` The key follows the same [order](#api-keys) as a chat section's: `llm4s.embeddings..apiKey` if you set it, otherwise the vendor's shared `llm4s.credentials..apiKey`. So one `OPENAI_API_KEY` serves both OpenAI chat and OpenAI embeddings. To bill embeddings to another account, give the block its own key: ```hocon llm4s.embeddings.openai.apiKey = ${?OPENAI_EMBEDDINGS_API_KEY} ``` Each embedding provider comes from its module: `openai` from `llm4s-openai`, `voyage` from `llm4s-voyage`, `jina` from `llm4s-jina`, `cohere` from `llm4s-cohere` and `ollama` from `llm4s-ollama` (in `0.4.1` and earlier, `openai` and `voyage` are inside `llm4s-core`). Without the module, the provider id fails with an error naming the registered embedding providers. Default base URLs are used automatically: - OpenAI: `https://api.openai.com/v1` - Voyage: `https://api.voyageai.com/v1` - Jina: `https://api.jina.ai/v1` - Cohere: `https://api.cohere.com` (the API root; the provider posts to `/v2/embed`) - Ollama: `http://localhost:11434` Override base URLs if needed - each module's `reference.conf` binds a variable for it: ```bash OPENAI_EMBEDDING_BASE_URL=https://custom.openai.com/v1 # llm4s-openai VOYAGE_EMBEDDING_BASE_URL=https://custom.voyage.ai/v1 # llm4s-voyage JINA_EMBEDDING_BASE_URL=https://custom.jina.ai/v1 # llm4s-jina COHERE_EMBEDDING_BASE_URL=https://custom.cohere.com # llm4s-cohere OLLAMA_EMBEDDING_BASE_URL=http://embeddings-host:11434 # llm4s-ollama ``` ### Available Models **OpenAI:** - `text-embedding-3-small` - Fast, cost-effective (1536 dimensions) - `text-embedding-3-large` - Higher quality (3072 dimensions) - `text-embedding-ada-002` - Legacy model (1536 dimensions) **Voyage AI:** - `voyage-3` - General purpose - `voyage-3-large` - Higher quality - `voyage-code-2` - Code-optimized Voyage embeds queries and documents differently. Every request is sent with an `input_type` of `document` or `query`, taken from the request's `purpose` (see [Queries and documents](#queries-and-documents) below). Requests that do not say get `document`. **Jina AI:** - `jina-embeddings-v3` - Multilingual, 8192-token context (1024 dimensions) - `jina-embeddings-v4` - (2048 dimensions) Jina embeds queries and documents differently. The provider built from `EMBEDDING_MODEL` follows each request's `purpose` (see [Queries and documents](#queries-and-documents) below): a document is sent with the `retrieval.passage` task and a query with `retrieval.query`. To send one task whatever the request says, for example `text-matching`, build the provider with the typed `JinaTask` setting: `JinaEmbeddingProvider.fromConfig(config, JinaTask.TextMatching)`. An explicit task wins over the purpose. **Cohere:** - `embed-v4.0` - (1536 dimensions) - `embed-english-v3.0`, `embed-multilingual-v3.0` - (1024 dimensions) - `embed-english-light-v3.0`, `embed-multilingual-light-v3.0` - (384 dimensions) Cohere embeds queries and documents differently. The provider built from `EMBEDDING_MODEL` follows each request's `purpose` (see [Queries and documents](#queries-and-documents) below): a document is sent with `input_type` `search_document` and a query with `search_query`. To send one input type whatever the request says, for example `classification`, build the provider with the typed `CohereInputType` setting: `CohereEmbeddingProvider.fromConfig(config, CohereInputType.Classification)`. An explicit input type wins over the purpose. Texts are sent in requests of at most 96, Cohere's limit, and the vectors keep the model's default size. **Ollama (local):** - `nomic-embed-text` - General purpose (768 dimensions) - `mxbai-embed-large` - Higher quality (1024 dimensions) - `all-minilm` - Lightweight (384 dimensions) **Install Ollama embedding model:** ```bash ollama pull nomic-embed-text ``` ### Legacy Format (Still Supported) The legacy format using `EMBEDDING_PROVIDER` is still supported for backward compatibility: ```bash # OpenAI EMBEDDING_PROVIDER=openai OPENAI_EMBEDDING_BASE_URL=https://api.openai.com/v1 OPENAI_EMBEDDING_MODEL=text-embedding-3-small OPENAI_API_KEY=sk-... # Voyage AI EMBEDDING_PROVIDER=voyage VOYAGE_EMBEDDING_BASE_URL=https://api.voyageai.com/v1 VOYAGE_EMBEDDING_MODEL=voyage-3 VOYAGE_API_KEY=pa-... # Ollama EMBEDDING_PROVIDER=ollama OLLAMA_EMBEDDING_BASE_URL=http://localhost:11434 OLLAMA_EMBEDDING_MODEL=nomic-embed-text ``` ### Using Embeddings in Code ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.EmbeddingClient import org.llm4s.llmconnect.config.EmbeddingModelConfig import org.llm4s.agent.memory.LLMEmbeddingService import org.llm4s.model.ModelRegistryService // Core logic depends on injected service class RAGService(embeddingService: LLMEmbeddingService) { def processDocuments(docs: Seq[String]): Unit = { // Use embeddingService... } } // Configuration boundary object RAGApplication extends App { val startup = for { // 1. Load config embeddingConfig <- Llm4sConfig.embeddings() (provider, cfg) = embeddingConfig model <- Llm4sConfig.textEmbeddingModel() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry // 2. Build dependencies client <- EmbeddingClient.from(provider, cfg) // 3. Create service service = LLMEmbeddingService( client, EmbeddingModelConfig(model.modelName, model.dimensions) ) } yield new RAGService(service) startup.fold( err => println(s"Startup failed: $err"), service => println("RAG Service started successfully") ) } ``` ### Queries and documents Several embedding models embed a search query differently from a document, so a query only finds what was indexed if the two were embedded on matching sides. An `EmbeddingRequest` says which it is with its `purpose`: ```scala import org.llm4s.llmconnect.model.{ EmbeddingRequest, InputPurpose } val indexing = EmbeddingRequest(chunks, model) // a document: the default val searching = EmbeddingRequest(Seq(question), model, InputPurpose.Query) // a query ``` Each provider maps the purpose onto its own parameter: | Provider | `Document` | `Query` | |---|---|---| | Voyage | `input_type` `document` | `input_type` `query` | | Jina | `task` `retrieval.passage` | `task` `retrieval.query` | | Cohere | `input_type` `search_document` | `input_type` `search_query` | | OpenAI, Ollama | nothing sent: these models embed both alike | nothing sent | `RAG` and the benchmark `RAGPipeline` already do this: what they ingest is embedded as documents and the question they answer as a query. Code that does not say keeps embedding documents. - **An explicit setting wins.** A Jina task or a Cohere input type passed to the provider's `fromConfig` is sent for every request, whatever its purpose, because it can be one the purpose cannot express (`text-matching`, `classification`, `clustering`). - **Voyage now sends `input_type`.** Before, it sent none, which Voyage treats as a plain embedding. Documents indexed before this change were embedded that way; they still work against queries, and re-indexing makes the two sides match exactly. - **Caching keeps the two apart.** `CachedEmbeddingClient`'s key includes the request's purpose, so a query and a document with the same text never share an entry (see the [caching guide](../guide/caching)). ### System Properties (Alternative) When running via sbt, use system properties for reliable configuration: ```bash # Using unified format (recommended) sbt -Dllm4s.embeddings.model=ollama/nomic-embed-text \ "run" # Or with legacy format sbt -Dllm4s.embeddings.provider=ollama \ -Dllm4s.embeddings.ollama.model=nomic-embed-text \ "run" ``` --- ## Tracing Configuration ### Console Tracing (Development) ```bash TRACING_MODE=console ``` Console tracing is the default. It prints each event to stdout as metadata only - event type, model, ids, tool names, durations, token counts and costs - and withholds queries, model output, tool input/output, custom event data and error messages (`Query: [withheld: 42 chars]`). To print those too, while developing: ```bash LLM4S_TRACING_CONSOLE_CAPTURE_CONTENT=true # llm4s.tracing.console.captureContent = true ``` Keep it off wherever stdout is collected into logs; see [Data protection](../guide/data-protection). ### Langfuse (Production) Langfuse tracing needs the `llm4s-observability` module on the classpath: ```scala libraryDependencies += "org.llm4s" %% "llm4s-observability" % "" ``` ```bash TRACING_MODE=langfuse LANGFUSE_PUBLIC_KEY=pk-lf-... LANGFUSE_SECRET_KEY=sk-lf-... LANGFUSE_URL=https://cloud.langfuse.com ``` That module's `reference.conf` binds the `LANGFUSE_*` variables under `llm4s.tracing.langfuse`, so they can equally be set there in `application.conf`. Both keys are required: without one, `Tracing.fromSettings` returns a `ConfigurationError` naming `llm4s.tracing.langfuse.publicKey (LANGFUSE_PUBLIC_KEY)` and/or `secretKey`, and `Tracing.create` logs it and traces nothing. Without the module, `TRACING_MODE=langfuse` gives an error naming the `llm4s-observability` artifact. Get keys from [Langfuse](https://langfuse.com): 1. Sign up at langfuse.com 2. Create a project 3. Navigate to Settings → API Keys 4. Copy public and secret keys ### OpenTelemetry OpenTelemetry provides distributed tracing for observability across your infrastructure. **Setup:** ```bash TRACING_MODE=opentelemetry OTEL_SERVICE_NAME=llm4s-agent OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ``` OpenTelemetry tracing needs the `llm4s-observability-otel` module on the classpath. **Settings** (bound in core's `reference.conf`): | Config key | Variable | Default | Description | |----------|----------|---------|-------------| | `llm4s.tracing.mode` | `TRACING_MODE` | `console` | Must be `opentelemetry` | | `llm4s.tracing.opentelemetry.serviceName` | `OTEL_SERVICE_NAME` | `llm4s-agent` | Service name for trace identification | | `llm4s.tracing.opentelemetry.endpoint` | `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4317` | OpenTelemetry Collector endpoint | | `llm4s.tracing.opentelemetry.headers` | (none) | (empty) | Headers sent with every export | | `llm4s.tracing.opentelemetry.captureContent` | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | `false` | Record prompts and completions, redacted, as `gen_ai.input.messages` / `gen_ai.output.messages` | llm4s builds the OTLP exporter itself, so the OpenTelemetry SDK's own `OTEL_EXPORTER_OTLP_HEADERS` variable is **not** read. Set headers in `application.conf`, binding secrets as usual: ```hocon llm4s.tracing.opentelemetry.headers { Authorization = ${?OTEL_AUTH_HEADER} # e.g. "Bearer " } ``` **Example Configuration:** ```bash # Local Jaeger (via Docker) TRACING_MODE=opentelemetry OTEL_SERVICE_NAME=llm4s-agent OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 # Cloud Provider with Authentication TRACING_MODE=opentelemetry OTEL_SERVICE_NAME=llm4s-runner OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4317 OTEL_AUTH_HEADER="Bearer sk-otel-token" # bound by the headers block above ``` **Spans Generated:** ``` Span: LLM Completion ├─ gen_ai.request.model: gpt-4o ├─ gen_ai.usage.input_tokens: 150 └─ gen_ai.usage.output_tokens: 50 Span: Tool Execution: web_search ├─ tool.name: web_search ├─ tool.input: "Scala 3 features" └─ duration_ms: 1234 Span: Token Usage - completion ├─ operation: completion ├─ gen_ai.usage.total_tokens: 200 └─ cost.usd: 0.0042 Span: Agent State Updated ├─ status: RUNNING ├─ message_count: 5 └─ log_count: 12 ``` **Setting Up Jaeger (Local Development):** ```bash # Start Jaeger all-in-one docker run -d \ -p 6831:6831/udp \ -p 6832:6832/udp \ -p 5778:5778 \ -p 16686:16686 \ -p 14268:14268 \ -p 14250:14250 \ -p 9411:9411 \ jaegertracing/all-in-one:latest # Configure LLM4S export TRACING_MODE=opentelemetry export OTEL_SERVICE_NAME=llm4s-dev export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 # View traces at http://localhost:16686 ``` **Code Usage:** ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.trace.{ TraceEvent, Tracing } // Build the tracer that llm4s.tracing.mode selects. fromSettings fails if the mode's // backend - here llm4s-observability-otel - is not on the classpath; Tracing.create // would log that and fall back to NoOpTracing instead. val result = for { settings <- Llm4sConfig.tracing() tracer <- Tracing.fromSettings(settings) _ <- tracer.traceEvent(TraceEvent.AgentInitialized("query", Vector("tool1"))) _ <- tracer.traceTokenUsage(usage, "gpt-4o", "completion") _ = tracer.shutdown() // always shut down to flush pending spans } yield () ``` ### Other tracing backends `console` and `none` are built into `llm4s-core`; every other mode is served by a `org.llm4s.trace.spi.TracingBackend` that a module registers in `META-INF/services/org.llm4s.trace.spi.TracingBackend` - `langfuse` by `llm4s-observability`, `opentelemetry` by `llm4s-observability-otel`. A mode llm4s has no name for, such as `TRACING_MODE=datadog`, is read as `TracingMode.Named("datadog")` and goes to whichever backend declares that mode. With no such backend, `Tracing.create` logs an error listing the available modes and traces nothing. For `langfuse` and `opentelemetry` the error also names the module to add, since llm4s knows which of its own modules serves them: ```text Tracing mode 'opentelemetry' is configured but no TracingBackend for it is on the classpath. Add the 'org.llm4s' %% 'llm4s-observability-otel' dependency. Available modes: console, noop. ``` That hint is only a message: dispatch never consults it, and a third-party mode needs no entry in it. See the [observability guide](../guide/observability/#adding-a-tracing-backend) to write one. ### Disable Tracing ```bash TRACING_MODE=none ``` --- ## Environment variables llm4s reads These are the variables bound by a `${?VAR}` in some llm4s module's `reference.conf`, so they work without any configuration of your own. Each sets the config key beside it, which you can also set in `application.conf` or with `-D`. | Variable | Config key | Bound by | |---|---|---| | `TRACING_MODE` | `llm4s.tracing.mode` (`console` - the default - or `none`, both built in; `langfuse`, `opentelemetry`, or the mode of any other `TracingBackend` on the classpath) | `llm4s-core` | | `LANGFUSE_URL`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`, `LANGFUSE_ENV`, `LANGFUSE_RELEASE`, `LANGFUSE_VERSION` | `llm4s.tracing.langfuse.*` | `llm4s-observability` | | `OTEL_SERVICE_NAME`, `OTEL_EXPORTER_OTLP_ENDPOINT` | `llm4s.tracing.opentelemetry.serviceName`, `.endpoint` | `llm4s-observability-otel` | | `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` | `llm4s.tracing.opentelemetry.captureContent` (`false` by default: spans carry no prompt or completion content) | `llm4s-observability-otel` | | `EMBEDDING_MODEL` (or legacy `EMBEDDING_PROVIDER`) | `llm4s.embeddings.model` (`.provider`) | `llm4s-core` | | `LLM4S_TRACING_CONSOLE_CAPTURE_CONTENT` | `llm4s.tracing.console.captureContent` (`false` by default: console tracing prints no prompt or completion content) | `llm4s-core` | | `LLM4S_EXCHANGE_LOGGING_ENABLED`, `LLM4S_EXCHANGE_LOGGING_DIR` | `llm4s.exchangeLogging.*` | `llm4s-core` | | `HTTPS_PROXY`, `NO_PROXY`, `LLM4S_PROXY_USER`, `LLM4S_PROXY_PASSWORD` | `llm4s.http.proxy.url`, `.nonProxyHosts`, `.username`, `.password` ([Corporate networks](#corporate-networks-a-proxy-and-a-custom-ca)) | `llm4s-core` | | `LLM4S_TRUSTSTORE`, `LLM4S_TRUSTSTORE_PASSWORD`, `LLM4S_KEYSTORE`, `LLM4S_KEYSTORE_PASSWORD` | `llm4s.http.tls.trustStore`, `.trustStorePassword`, `.keyStore`, `.keyStorePassword` | `llm4s-core` | | `LLM4S_MODEL_REGISTRY_RESOURCE`, `LLM4S_MODEL_REGISTRY_FILE`, `LLM4S_MODEL_REGISTRY_URL` | `llm4s.modelRegistry.*` | `llm4s-core` | | `WORKSPACE_DIR`, `WORKSPACE_IMAGE`, `WORKSPACE_PORT` | `llm4s.workspace.*` | `llm4s-core` | | `BRAVE_SEARCH_API_KEY`, `EXA_API_KEY` and the other `BRAVE_*`, `EXA_*` variables, `DUCK_DUCK_GO_SEARCH_API_URL` | `llm4s.tools.*` | `llm4s-core` | | `OPENAI_API_KEY` | `llm4s.credentials.openai.apiKey` (OpenAI chat sections and embeddings) | `llm4s-openai` | | `AZURE_OPENAI_API_KEY` | `llm4s.credentials.azure.apiKey` | `llm4s-openai` | | `REQUESTY_API_KEY` | `llm4s.credentials.requesty.apiKey` | `llm4s-openai` | | `ANTHROPIC_API_KEY` | `llm4s.credentials.anthropic.apiKey` | `llm4s-anthropic` | | `GOOGLE_API_KEY`, else `GEMINI_API_KEY` | `llm4s.credentials.gemini.apiKey` | `llm4s-gemini` | | `DEEPSEEK_API_KEY`, `ZAI_API_KEY`, `OPENROUTER_API_KEY`, `MISTRAL_API_KEY`, `XAI_API_KEY` | `llm4s.credentials..apiKey` | `llm4s-openai-compatible` | | `COHERE_API_KEY` | `llm4s.credentials.cohere.apiKey` (Cohere chat, the Cohere reranker and Cohere embeddings) | `llm4s-openai-compatible`, `llm4s-rag`, `llm4s-cohere` | | `VOYAGE_API_KEY` | `llm4s.credentials.voyage.apiKey` | `llm4s-voyage` | | `JINA_API_KEY` | `llm4s.credentials.jina.apiKey` | `llm4s-jina` | | `TYPESAFE_API_KEY` | `llm4s.credentials.typesafe.apiKey` (the Jev decision client) | `llm4s-jev` | | `TYPESAFE_BASE_URL`, `TYPESAFE_DEFAULT_MODEL` | `llm4s.jev.baseUrl`, `llm4s.jev.model` | `llm4s-jev` | | `OPENAI_EMBEDDING_BASE_URL`, `OPENAI_EMBEDDING_MODEL` | `llm4s.embeddings.openai.*` | `llm4s-openai` | | `VOYAGE_EMBEDDING_BASE_URL`, `VOYAGE_EMBEDDING_MODEL` | `llm4s.embeddings.voyage.*` | `llm4s-voyage` | | `JINA_EMBEDDING_BASE_URL`, `JINA_EMBEDDING_MODEL` | `llm4s.embeddings.jina.*` | `llm4s-jina` | | `COHERE_EMBEDDING_BASE_URL`, `COHERE_EMBEDDING_MODEL` | `llm4s.embeddings.cohere.*` | `llm4s-cohere` | | `OLLAMA_EMBEDDING_BASE_URL`, `OLLAMA_EMBEDDING_MODEL` | `llm4s.embeddings.ollama.*` | `llm4s-ollama` | | `RERANK_PROVIDER`, `COHERE_RERANK_BASE_URL`, `COHERE_RERANK_MODEL` | `llm4s.rerank.*`, read by `RerankerConfigLoader` | `llm4s-rag` | | `LLM4S_CHECKPOINTER` | `llm4s.agent.checkpointer.kind` (`memory` - the default, built in - `sqlite`, `postgres`, or the kind of any other `CheckpointerBackend` on the classpath) | `llm4s-agent` | | `LLM4S_RUNTIME_LABEL` | `llm4s.agent.runtime.claims.label` (names the runtime holding a thread's claim in the logs) | `llm4s-agent` | | `LLM4S_WORKER_LABEL` | `llm4s.agent.worker.label` (names an `AgentWorker` in its queue leases and logs) | `llm4s-agent` | | `LLM4S_CHECKPOINTER_SQLITE_PATH` | `llm4s.agent.checkpointer.sqlite.path` | `llm4s-agent-checkpoint-sqlite` | | `LLM4S_CHECKPOINTER_POSTGRES_URL`, `LLM4S_CHECKPOINTER_POSTGRES_USER`, `LLM4S_CHECKPOINTER_POSTGRES_PASSWORD` | `llm4s.agent.checkpointer.postgres.url`, `.user`, `.password` | `llm4s-agent-checkpoint-postgres` | | `PGVECTOR_HOST`, `PGVECTOR_PORT`, `PGVECTOR_DATABASE`, `PGVECTOR_USER`, `PGVECTOR_PASSWORD`, `PGVECTOR_TABLE`, ... | `llm4s.rag.permissions.pg.*` | `llm4s-rag` | | `ELASTICSEARCH_URL`, `ELASTICSEARCH_INDEX`, `ELASTICSEARCH_API_KEY`, `ELASTICSEARCH_USERNAME`, `ELASTICSEARCH_PASSWORD` | `llm4s.vectorstore.elasticsearch.*`, read by `ElasticsearchVectorStoreConfigLoader` | `llm4s-vectorstore-elasticsearch` | | `OPENSEARCH_URL`, `OPENSEARCH_INDEX`, `OPENSEARCH_USERNAME`, `OPENSEARCH_PASSWORD`, `OPENSEARCH_TOKEN` | `llm4s.vectorstore.opensearch.*`, read by `OpenSearchVectorStoreConfigLoader` | `llm4s-vectorstore-elasticsearch` | | `REDIS_HOST`, `REDIS_PORT`, `REDIS_TLS`, `REDIS_USERNAME`, `REDIS_PASSWORD`, `REDIS_VECTOR_INDEX` | `llm4s.vectorstore.redis.*`, read by `RedisVectorStoreConfigLoader` | `llm4s-vectorstore-redis` | **Not read by llm4s** unless your `application.conf` binds them: `LLM_MODEL`, `LLM4S_PROVIDER`, every chat provider's endpoint and model variable (`AZURE_API_BASE`, `AZURE_API_VERSION`, `OPENAI_BASE_URL`, `OLLAMA_BASE_URL`, ...), `AZURE_API_KEY` (the Azure key is `AZURE_OPENAI_API_KEY`, the openai SDK's name), `CO_API_KEY`, anything for the generic `openai-compatible` provider, and `OTEL_EXPORTER_OTLP_HEADERS`. A shared key sets only `apiKey`: `baseUrl`, `endpoint`, `model` and `apiVersion` always come from the section. `LLM_MODEL` was removed with legacy single-provider loading in [#903](https://github.com/llm4s/llm4s/issues/903). The embeddings sample's variables - `EMBEDDING_INPUT_PATH`, `EMBEDDING_INPUT_PATHS`, `EMBEDDING_QUERY`, `CHUNK_SIZE`, `CHUNK_OVERLAP`, `CHUNKING_ENABLED`, `MAX_ROWS_PER_FILE`, `TOP_DIMS_PER_ROW`, `GLOBAL_TOPK`, `SHOW_GLOBAL_TOP`, `COLOR`, `TABLE_WIDTH`, `ENABLE_EXPERIMENTAL_STUBS` and `LLM4S_EMBEDDINGS_LOCAL_{IMAGE,AUDIO,VIDEO}_MODEL` - are bound by the samples' own `application.conf` under `llm4s.samples.embeddings`, not by a library module ([#2265](https://github.com/llm4s/llm4s/issues/2265)). The example agent service's variables - `PORT`, `LLM4S_SERVICE_MODEL`, `LLM4S_SERVICE_TOOL_DELAY` and `LLM4S_SERVICE_METRICS` - are bound by `modules/deploy-service`'s own `reference.conf` (unpublished) under `llm4s.deploy-service`; see [`deploy/README.md`](https://github.com/llm4s/llm4s/blob/main/deploy/README.md). --- ## Environment-specific configuration Keep the provider structure in HOCON and inject secrets from the environment. One `application.conf` can serve every environment, since only the selected section's variables need to be set, or you can keep one file per environment: ```hocon # src/main/resources/application.conf - development llm4s { providers { provider = "ollama-local" ollama-local { provider = "ollama" model = "llama3.2" baseUrl = "http://localhost:11434" } } tracing.mode = "console" } ``` ```hocon # src/main/resources/prod.conf - production include "application.conf" llm4s { providers = null # drop the development sections providers { provider = "claude" claude { provider = "anthropic" model = "claude-sonnet-4-20250514" apiKey = ${?ANTHROPIC_API_KEY} # explicit in production: which account this bills } } tracing.mode = "langfuse" # llm4s-observability; LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY from the environment } ``` ```bash java -Dconfig.resource=prod.conf -jar app.jar ``` `providers = null` matters: without it the included `ollama-local` section would stay, and be validated, in production. --- ## Configuration Best Practices ### ✅ DO 1. **Keep secrets in environment variables** - the vendor's own variable (`OPENAI_API_KEY`), or one you bind with `apiKey = ${?VAR}` in a section; in production, give each section its own 2. **Keep structure in `application.conf`**: provider sections, models, defaults 3. **Add `.env` files to `.gitignore`** if you use them to export variables 4. **Use different configs** for dev/staging/prod 5. **Validate configuration** at startup ```scala import org.llm4s.config.Llm4sConfig object ApplicationBoundary { def validateAndStart(): Unit = { // Fail fast at startup if config is invalid Llm4sConfig.defaultProvider().fold( error => { Console.err.println(s"FATAL: Configuration error: ${error.formatted}") System.exit(1) }, config => { println(s"Configuration loaded for model: ${config.model}") // startApplication(config) } ) } } ``` ### ❌ DON'T 1. **Don't hardcode API keys** in source code or `application.conf` 2. **Don't commit .env files** to version control 3. **Don't use System.getenv() directly** (use Llm4sConfig) 4. **Don't mix production keys** in development 5. **Don't skip validation** - fail fast on bad config --- ## Validation Example ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService object ValidateConfig extends App { println("Validating LLM4S configuration...") // Perform validation at the application edge val validationResult = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) } yield (providerConfig, client) validationResult match { case Right((config, client)) => println(s"✅ Model: ${config.model}") println(s"✅ Provider: ${config.getClass.getSimpleName}") println(s"✅ Client ready: ${client.getClass.getSimpleName}") println("✅ Configuration valid!") case Left(error) => Console.err.println(s"❌ Configuration error: ${error.formatted}") System.exit(1) } } ``` --- ## Troubleshooting ### Problem: "missing required fields: apiKey" **Symptoms:** ``` ConfigurationError: Provider 'openai-main' (provider = openai) is missing required fields: - apiKey: set OPENAI_API_KEY, or set apiKey under llm4s.providers.openai-main in application.conf ``` **Root causes:** 1. Neither the vendor's variable (`OPENAI_API_KEY`) nor a section `apiKey` is set in this process 2. The provider's module is missing, so nothing binds its variable (see the table in [Named provider sections](#named-provider-sections)) 3. The section binds its own variable (`apiKey = ${?OTHER_VAR}`) that is unset, and the vendor's variable is unset too 4. `.env` file not loaded in the shell that starts the JVM The error names the section it is about, and only a section being loaded is validated. If it names a section you did not ask for, check `llm4s.providers.provider` (and any `-Dllm4s.providers.provider` override): that is the section `defaultProvider()` loads. The one call that validates every section is `Llm4sConfig.providers()`. **Debug steps:** ```bash # Is the vendor's variable set in this shell? echo $OPENAI_API_KEY # Does the section set a key of its own? (an apiKey line wins over OPENAI_API_KEY) grep -n apiKey src/main/resources/application.conf ``` ### Problem: "Configured provider '...' was not found" **Symptoms:** ``` ConfigurationError: Configured provider 'openai-main' was not found ``` **Fix:** `llm4s.providers.provider` (or the name passed to `Llm4sConfig.provider(...)`) must match a section name exactly. Check for a typo, and for an `LLM4S_PROVIDER`-style override selecting a section this file does not define. If `llm4s.providers.provider` is absent altogether, `defaultProvider()` fails with "No default provider configured under llm4s.providers.provider" - set it, or call `Llm4sConfig.provider("")`. ### Problem: "Unknown provider" / provider not registered **Symptoms:** an error naming `llm4s.providers..provider` and listing the registered providers. **Fix:** the section's `provider = "..."` id comes from a module that is not on the classpath. Add it (see the table in [Named provider sections](#named-provider-sections)), or fix the id's spelling. ### Problem: "Configuration not loading from application.conf" **Symptoms:** `defaultProvider()` behaves as if your sections were not there. **Debug:** ```scala import com.typesafe.config.ConfigFactory val config = ConfigFactory.load() println(config.hasPath("llm4s.providers.provider")) // Should be true println(config.getString("llm4s.providers.provider")) // Should name your section ``` **Common causes:** - application.conf not in `src/main/resources/` (or not on the runtime classpath) - `-Dconfig.file` / `-Dconfig.resource` pointing somewhere else - HOCON syntax error (missing quotes, wrong nesting) - A `-D` system property overriding the key (system properties have the highest precedence) ### Problem: "Provider mismatch" **Symptoms:** ``` import org.llm4s.error.AuthenticationError Left(AuthenticationError("Invalid API key")) ``` **Root cause:** the section's own `apiKey` binds a variable holding another provider's key, or the vendor's variable holds the wrong key. The INFO line `llm4s.providers.: API key from ` says which place the key came from. ```hocon # ❌ Wrong: an OpenAI section reading the Anthropic key openai-main { provider = "openai" model = "gpt-4o" apiKey = ${?ANTHROPIC_API_KEY} } ``` ### Problem: "Rate limiting / 429 errors" **Symptoms:** ``` import org.llm4s.error.RateLimitError Left(RateLimitError("Rate limit exceeded")) ``` **Solutions:** 1. **Add retry logic:** ```scala import scala.concurrent.duration._ import org.llm4s.error.RateLimitError import org.llm4s.types.Result // Note: This example uses Thread.sleep for simplicity. // In production, prefer scala.concurrent or cats-effect for non-blocking delays. def retryWithBackoff[A](op: => Result[A], maxRetries: Int = 3): Result[A] = { (1 to maxRetries).foldLeft(op) { (result, attempt) => result match { case Left(_: RateLimitError) if attempt < maxRetries => Thread.sleep(Math.pow(2, attempt).toLong * 1000) // Exponential backoff op case other => other } } } ``` 2. **Use a cheaper model for testing** - change the section's `model`: ```hocon openai-main { provider = "openai" model = "gpt-4o-mini" # development; gpt-4o in production } ``` 3. **Switch to Ollama for development** - point `llm4s.providers.provider` at an `ollama-local` section (see [Switching providers](#switching-providers)); free, no rate limits. ### Problem: "Slow responses or timeouts" **Symptoms:** Requests take 30+ seconds or timeout. **Solutions:** 1. **Raise (or lower) the timeouts.** Each client has a default (see [Timeouts](#timeouts)): two minutes for a completion, and five or ten minutes for a stream. A section's `timeouts { request = 5m, stream = 20m }` block changes them per provider. 2. **Use streaming for long responses:** ```scala // Non-streaming: waits for full response client.complete(Conversation(Seq(UserMessage("Your query")))) // Streaming: get tokens as they arrive client.streamComplete( Conversation(Seq(UserMessage("Your query"))), onChunk = chunk => chunk.content.foreach(print) ) ``` 3. **Reduce response length:** ```scala client.complete( Conversation(Seq(UserMessage("Your query"))), CompletionOptions(maxTokens = Some(500)) // Limit response length ) ``` ### Problem: "OutOfMemoryError with embeddings" **Symptoms:** JVM crashes when processing large document collections. **Solutions:** 1. **Batch embeddings instead of loading all at once:** ```scala import org.llm4s.llmconnect.model.EmbeddingRequest val documents: List[String] = loadDocuments() val batchSize = 100 val embeddings = documents.grouped(batchSize).flatMap { batch => embedder.embed(EmbeddingRequest(batch, embeddingModel)) match { case Right(response) => response.embeddings case Left(err) => println(s"Batch failed: $err") List.empty } }.toList ``` 2. **Increase JVM heap:** ```bash export SBT_OPTS="-Xmx4G -Xss4M" sbt run ``` 3. **Use a local embedding model (smaller memory footprint):** ```bash EMBEDDING_MODEL=ollama/nomic-embed-text ``` --- ## Next Steps Configuration complete! Now you can: 1. **[Explore features →](next-steps)** - Dive into agents, tools, and more 2. **[Browse examples →](/examples/)** - See configuration in action 3. **[User guide →](../guide/basic-usage)** - Learn core concepts 4. **[Observability →](../guide/observability)** - Set up tracing --- ## Additional Resources - **[Llm4sConfig API](/api/#4-explicit-configuration)** - Detailed API documentation - **[Environment variables llm4s reads](#environment-variables-llm4s-reads)** - Complete list - **[Production Guide](../PRODUCTION_DEPLOYMENT)** - Production best practices - **[Discord Community](https://discord.gg/4uvTPn6qww)** - Get help with configuration --- **Ready to build?** [Explore what's next →](next-steps) --- Source: https://llm4s.org/getting-started/next-steps # Next Steps You've completed the getting started guide! Here's where to go next. --- ## 🎉 Congratulations! You've successfully: ✅ Installed LLM4S ✅ Written your first LLM program ✅ Configured providers and API keys ✅ Understood Result-based error handling Now let's explore what you can build with LLM4S! --- ## Learning Paths Choose your path based on what you want to build: ### 🤖 Path 1: Build Agents **Best for:** Interactive applications, chatbots, assistants **What you'll learn:** - Agent framework basics - Multi-turn conversations - Tool calling and integration - Conversation state management **Start here:** 1. [Agent Framework Guide](/examples/#agent-examples) 2. [Single-Step Agent Example](/examples/#single-step) 3. [Multi-Turn Conversations](/guide/agents/#multi-turn-conversations) 4. [Tools in the Agents Guide](/guide/agents/#agent-with-tools) **Example project ideas:** - Customer support chatbot - Code review assistant - Research assistant with web search - Interactive game master --- ### 🛠️ Path 2: Tool Integration **Best for:** LLMs that interact with external systems **What you'll learn:** - Defining custom tools - Tool parameter schemas - Model Context Protocol (MCP) - Tool error handling **Start here:** 1. [Built-in Tools Guide](/guide/builtin-tools) 2. [Weather Tool Example](/examples/#weather) 3. [MCP Tool Example](/examples/#mcp-tool) 4. [Multi-Tool Example](/examples/#multi-tool) **Example project ideas:** - Database query assistant - API integration agent - File system navigator - Task automation system --- ### 💬 Path 3: Conversational AI **Best for:** Chat applications, dialogue systems **What you'll learn:** - Context window management - Conversation persistence - History pruning strategies - Streaming responses **Start here:** 1. [Multi-Turn Conversations](/guide/agents/#multi-turn-conversations) 2. [Context Window Pruning](/guide/context-window-pruning) 3. [Streaming Events Guide](/guide/agents/streaming) 4. [Long Conversation Example](/examples/#long-conversation) **Example project ideas:** - Slack bot - Discord integration - Customer service chat - Educational tutor --- ### 🔍 Path 4: RAG & Knowledge **Best for:** Question answering, document search, knowledge bases **What you'll learn:** - Vector embeddings - Semantic search - Document processing - Retrieval-augmented generation **Start here:** 1. [Vector Store Guide](/guide/vector-store) 2. [RAG for Enterprise](/guide/patterns/rag-enterprise) 3. [Embedding Example](/examples/#embedding-example) 4. [Vector Search](/guide/vector-store#search) **Example project ideas:** - Documentation Q&A system - PDF analyzer - Knowledge base search - Code search engine --- ### 📊 Path 5: Production Systems **Best for:** Deploying LLM apps to production **What you'll learn:** - Error handling patterns - Observability and tracing - Performance optimization - Security best practices **Start here:** 1. [Production Monitoring](/guide/patterns/production-monitoring) 2. [Observability Guide](../guide/observability) 3. [Error Handling](/guide/error-handling) 4. [Security Guide](/guide/patterns/security-best-practices) **Example project ideas:** - Scalable API service - Multi-tenant SaaS application - Enterprise integration - Monitoring dashboard --- ## Quick Reference: Key Features The snippets in this section assume a `client: LLMClient` (built as in [your first program](first-example)) and, for agents, a `tools: ToolRegistry`. ### Agents Build sophisticated multi-turn agents with automatic tool calling. ```scala import org.llm4s.agent.Agent val agent = Agent.builder("assistant", client).withTools(tools).build() // Result[Agent] val result = agent.flatMap(_.run("Your query")) ``` [Learn more →](/examples/#agent-examples) --- ### Tool Calling Give LLMs access to external functions and APIs. ```scala import org.llm4s.toolapi.{ Schema, ToolBuilder } def search(query: String): String = s"Results for $query" // your implementation val searchSchema = Schema .`object`[Map[String, Any]]("Search parameters") .withProperty(Schema.property("query", Schema.string("What to search for"))) val tool = ToolBuilder[Map[String, Any], String]("search", "Search the web", searchSchema) .withHandler(params => params.getString("query").map(search)) .buildSafe() // Result[ToolFunction[Map[String, Any], String]] ``` [Learn more →](/guide/builtin-tools) --- ### Multi-Turn Conversations Functional conversation management without mutation. ```scala import org.llm4s.agent.Agent val turns = for { agent <- Agent.builder("assistant", client).withTools(tools).build() result1 <- agent.run("First question") result2 <- agent.continueConversation(result1, "Next question") } yield result2 ``` [Learn more →](/guide/agents/#multi-turn-conversations) --- ### Context Management Automatically manage token windows and prune history. ```scala import org.llm4s.agent.{ ContextWindowConfig, PruningStrategy } val config = ContextWindowConfig( maxMessages = Some(20), pruningStrategy = PruningStrategy.OldestFirst ) ``` [Learn more →](/guide/context-window-pruning) --- ### Streaming Get real-time token-by-token responses. ```scala import org.llm4s.llmconnect.model.{ Conversation, UserMessage } val conversation = Conversation(Seq(UserMessage("Tell me a story"))) client.streamComplete(conversation, onChunk = chunk => chunk.content.foreach(print)) ``` [Learn more →](/guide/agents/streaming) --- ### Observability Trace LLM calls with Langfuse integration. ```bash # Automatic tracing when configured (with llm4s-observability on the classpath) export TRACING_MODE=langfuse ``` [Learn more →](../guide/observability) --- ### Embeddings Create and search vector embeddings. `EmbeddingClient` (in `llm4s-core`) turns text into vectors through an embedding provider module, and `llm4s-rag` indexes and searches them in a vector store. [Learn more →](/guide/vector-store) --- ### MCP Integration Connect to external Model Context Protocol servers. ```scala import org.llm4s.mcp.{ MCPServerConfig, MCPToolRegistry } // llm4s-mcp: an MCPToolRegistry is a ToolRegistry, so an agent takes it like any other val tools = new MCPToolRegistry(Seq(MCPServerConfig.stdio("files", Seq("npx", "my-mcp-server")))) ``` [Learn more →](/examples/#mcp-examples) --- ## Example Gallery Browse the working examples organized by category: ### Basic Examples - [Basic LLM Calling](/examples/#basic-llm-calling) - [Streaming Responses](/examples/#streaming) - [Multi-Provider Setup](/guide/basic-usage#multi-provider-pattern) - [Ollama (Local Models)](/examples/#ollama) - [Tracing Integration](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/basic/BasicLLMCallingWithTrace.scala) [View all basic examples →](/examples/#basic-examples) ### Agent Examples - [Single-Step Agent](/examples/#single-step) - [Multi-Step Agent](/examples/#multi-step) - [Multi-Turn Conversations](/examples/#multi-turn) - [Long Conversations](/examples/#long-conversation) - [Conversation Persistence](/examples/#persistence) - [MCP Agent](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/agent/MCPAgentExample.scala) [View all agent examples →](/examples/#agent-examples) ### Tool Examples - [Weather Tool](/examples/#weather) - [Multi-Tool Agent](/examples/#multi-tool) - [Tool Error Messages](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/toolapi/ErrorMessageDemonstration.scala) - [MCP Tools](/examples/#mcp-tool) [View all tool examples →](/examples/#tool-examples) ### Context Management Examples - [Context Pipeline](/examples/#context-pipeline) - [Token Windows](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/context/tokens/TokenWindowExample.scala) - [History Digest](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/context/HistoryDigestExample.scala) - [Compression](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/context/DeterministicCompressionExample.scala) - [Tool Externalization](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/context/ToolExternalizationExample.scala) [View all context examples →](/examples/#context-management) ### More Examples - [Embeddings](/examples/#embeddings) - [MCP Integration](/examples/#mcp-examples) - [Streaming](/examples/#streaming-examples) [Browse all examples →](/examples/) --- ## Common Recipes ### Recipe 1: Simple Q&A Bot ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model._ import org.llm4s.model.ModelRegistryService def askQuestion(question: String): String = { val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) response <- client.complete( Conversation( Seq( SystemMessage("You are a helpful Q&A assistant."), UserMessage(question) ) ) ) } yield response.content result.fold(_ => "Sorry, I couldn't process that question.", identity) } ``` ### Recipe 2: Agent with Custom Tools ```scala import org.llm4s.agent.Agent import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService import org.llm4s.toolapi.{ Schema, ToolBuilder, ToolRegistry } def getCurrentTime(): String = java.time.LocalDateTime.now().toString val result = for { timeTool <- ToolBuilder[Map[String, Any], String]( "get_time", "Get current date and time", Schema.`object`[Map[String, Any]]("No parameters") ).withHandler(_ => Right(getCurrentTime())).buildSafe() providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) agent <- Agent.builder("time-agent", client).withTools(new ToolRegistry(Seq(timeTool))).build() result <- agent.run("What time is it?") } yield result.answer ``` ### Recipe 3: Streaming Chat ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model.{ Conversation, UserMessage } import org.llm4s.model.ModelRegistryService def streamChat(message: String): Unit = { val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) _ <- client.streamComplete( Conversation(Seq(UserMessage(message))), onChunk = chunk => chunk.content.foreach(print) ) } yield println() result.left.foreach(error => println(s"Error: $error")) } ``` ### Recipe 4: Multi-Turn with Pruning ```scala import org.llm4s.agent.{ Agent, ContextWindowConfig, PruningStrategy } import org.llm4s.agent.graph.middleware.ContextWindowMiddleware val config = ContextWindowConfig( maxMessages = Some(20), preserveSystemMessage = true, pruningStrategy = PruningStrategy.OldestFirst ) // Pruning is middleware on the agent: it trims what each model call is sent, // and the thread keeps the full history (client and tools as in the quick reference above) val result = for { agent <- Agent.builder("assistant", client) .withTools(tools) .withMiddleware(ContextWindowMiddleware(config)) .build() first <- agent.run("First question") // Turn 1 second <- agent.continueConversation(first, "Follow-up question") // Turn 2, pruned when sent } yield second ``` --- ## Troubleshooting ### Common Issues **Problem: API key errors** - Check the vendor's variable is set in the shell that runs your app: `echo $OPENAI_API_KEY` - a section without an `apiKey` of its own uses it ([API keys](configuration#api-keys)) - If the section sets its own `apiKey = ${?SOME_VAR}`, that wins: check `SOME_VAR` instead - Check key starts with correct prefix (`sk-` for OpenAI, `sk-ant-` for Anthropic) **Problem: Model not found** - Verify `model` in the provider section is the provider's own model name (e.g. `gpt-4o-mini`, no `openai/` prefix) - Check `llm4s.providers.provider` names the section you meant - Check provider supports that model - Try a different model **Problem: Slow responses** - Use streaming for real-time feedback - Consider using a faster model (gpt-3.5-turbo, claude-haiku) - Check your internet connection **Problem: Token limit errors** - Implement context window pruning - Use shorter system prompts - Summarize conversation history [Full troubleshooting guide →](/reference/troubleshooting) --- ## Community & Support ### Get Help - **Discord**: [Join our community](https://discord.gg/4uvTPn6qww) - Active community for questions - **GitHub Issues**: [Report bugs](https://github.com/llm4s/llm4s/issues) - Bug reports and feature requests - **Documentation**: Browse the [user guide](../guide/basic-usage) - Comprehensive guides - **Examples**: Check [working examples](/examples/) - runnable code samples ### Stay Updated - **GitHub**: [Star the repo](https://github.com/llm4s/llm4s) - Get notified of updates - **Roadmap**: [View the roadmap](/reference/roadmap) - See what's coming - **Changelog**: [Release notes](https://github.com/llm4s/llm4s/releases) - Track changes ### Contribute - **Starter Kit**: Use [llm4s.g8](https://github.com/llm4s/llm4s.g8) to scaffold projects - **Share Examples**: Post your projects in Discord - **Contribute**: See the [contributing guide](/reference/contributing) --- ## Recommended Learning Order ### Week 1: Fundamentals 1. ✅ Complete Getting Started (you are here!) 2. Read [Basic Usage Guide](../guide/basic-usage) 3. Try [Basic Examples](/examples/#basic-examples) 4. Experiment with different providers ### Week 2: Agents & Tools 1. Read [Agent Framework](/examples/#agent-examples) 2. Build a simple agent with one tool 3. Try [Tool Examples](/examples/#tool-examples) 4. Add multiple tools ### Week 3: Advanced Patterns 1. Implement [Multi-Turn Conversations](/guide/agents/#multi-turn-conversations) 2. Add [Context Window Pruning](/guide/context-window-pruning) 3. Set up [Observability](../guide/observability) 4. Try [Long Conversation Example](/examples/#long-conversation) ### Week 4: Production 1. Read [Production Monitoring](/guide/patterns/production-monitoring) 2. Implement error handling 3. Add monitoring and tracing 4. Deploy your first production agent --- ## Quick Links ### Documentation - [User Guide](../guide/basic-usage) - Feature guides - [API Reference](/api/) - API docs - [Advanced Topics](/advanced/) - Production topics ### Examples - [All Examples](/examples/) - Browse all examples - [Basic](/examples/#basic-examples) - Getting started - [Agents](/examples/#agent-examples) - Agent patterns - [Tools](/examples/#tool-examples) - Tool integration ### Reference - [Configuration](/getting-started/configuration) - Setup guide - [Migration Guide](/reference/migration) - Version upgrades - [Roadmap](/reference/roadmap) - Future plans --- ## What to Build? Need inspiration? Here are some project ideas: **Beginner Projects:** - Simple Q&A bot - Code explainer - Translation service - Writing assistant **Intermediate Projects:** - Multi-tool research agent - Database query interface - API integration bot - Document summarizer **Advanced Projects:** - Multi-agent system - RAG-powered knowledge base - Production chatbot service - Custom tool ecosystem --- ## Ready to Build? Pick your learning path and start building:

🤖 Build Agents

Agent Framework →

🛠️ Add Tools

Tools →

💬 Chat Apps

Multi-Turn →

🔍 RAG Systems

Vector Store →
--- **Happy building with LLM4S!** 🚀 Questions? [Join our Discord](https://discord.gg/4uvTPn6qww) --- Source: https://llm4s.org/getting-started/ollama-quickstart # Ollama Quick Start Guide Run LLM4S with Ollama for **free, local LLM inference** - no API keys required! --- ## Why Ollama? **Ollama** is the easiest way to run large language models locally on your machine: - ✅ **100% Free** - No API costs or rate limits - ✅ **Private** - Your data never leaves your machine - ✅ **Fast** - Low latency for local inference - ✅ **Offline** - Works without internet connection - ✅ **Multiple Models** - Easy model switching (llama2, mistral, phi, etc.) Perfect for **development**, **testing**, and **production** workloads where privacy matters. --- ## Prerequisites - **JDK 21** - **Scala 3.7.x** (llm4s 0.5.1 is built with 3.7.1) - **sbt 1.10.6+** - **4-8GB RAM** (depending on model size) No API keys needed! 🎉 Ollama support ships in its own artifact, alongside `llm4s-core`: ```scala val llm4sVersion = "0.5.1" libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-core" % llm4sVersion, "org.llm4s" %% "llm4s-ollama" % llm4sVersion, "org.llm4s" %% "llm4s-agent" % llm4sVersion // only for the agent under "Tool Calling" ) ``` Adding `llm4s-ollama` registers the provider; there is nothing else to wire up. Before 0.5.0, Ollama was part of `llm4s-core`. See the [installation guide](installation#for-ollama-local-models). --- ## Step 1: Install Ollama ### macOS / Linux ```bash curl -fsSL https://ollama.com/install.sh | sh ``` ### Windows Download the installer from [ollama.com/download](https://ollama.com/download) Or use PowerShell: ```powershell # Download and install Ollama winget install Ollama.Ollama ``` ### Verify Installation ```bash ollama --version # Should output: ollama version is 0.x.x ``` --- ## Step 2: Start Ollama Server ### Start the Server ```bash ollama serve ``` The server will start on **http://localhost:11434** by default. > On Windows/macOS, Ollama may start automatically as a background service. Check your system tray/menu bar. ### Verify Server is Running ```bash curl http://localhost:11434 # Should output: Ollama is running ``` --- ## Step 3: Pull a Model Ollama models are pulled on-demand. Let's start with **Mistral 7B** (fast and capable): ```bash ollama pull mistral ``` ### Available Models | Model | Size | RAM Required | Best For | Pull Command | |-------|------|--------------|----------|--------------| | **mistral** | 4.1GB | 8GB | General purpose, fast | `ollama pull mistral` | | **llama2** | 3.8GB | 8GB | Good balance | `ollama pull llama2` | | **phi** | 1.6GB | 4GB | Lightweight, fast | `ollama pull phi` | | **neural-chat** | 4.1GB | 8GB | Conversational | `ollama pull neural-chat` | | **codellama** | 3.8GB | 8GB | Code generation | `ollama pull codellama` | | **llama3.2** | 2.0GB | 8GB | Latest Llama | `ollama pull llama3.2` | | **gemma2** | 5.4GB | 8GB | Google's model | `ollama pull gemma2` | > **Recommendation**: Start with `mistral` for the best balance of speed and quality. ### List Downloaded Models ```bash ollama list ``` --- ## Step 4: Configure LLM4S LLM4S reads providers from named sections in your `application.conf`, not from environment variables such as `LLM_MODEL` - nothing in the library reads those. See the [Configuration Guide](configuration#named-provider-sections) for the full picture. ### Add the dependencies ```scala // build.sbt libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-core" % llm4sVersion, "org.llm4s" %% "llm4s-ollama" % llm4sVersion // the Ollama provider ) ``` Before 0.5.0 the Ollama provider shipped inside `llm4s-core`, so `llm4s-core` alone was enough. ### Add an `ollama-local` section Create `src/main/resources/application.conf`: ```hocon llm4s { providers { provider = "ollama-local" # the default: the name of a section below ollama-local { provider = "ollama" model = "mistral" # baseUrl defaults to http://localhost:11434, where `ollama serve` listens } } } ``` No API key is needed. If you want to take the model or server from the environment, bind the variables yourself - the names are yours to choose: ```hocon ollama-local { provider = "ollama" model = "mistral" model = ${?OLLAMA_MODEL} # optional override from the environment baseUrl = ${?OLLAMA_BASE_URL} # unset: the default, http://localhost:11434 } ``` --- ## Step 5: Write Your First LLM4S + Ollama App Create `HelloOllama.scala`: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model._ import org.llm4s.model.ModelRegistryService object HelloOllama extends App { // Create a conversation with system and user messages val conversation = Conversation(Seq( SystemMessage("You are a helpful AI assistant."), UserMessage("Explain what Scala is in one sentence.") )) // Load the default provider section and make the request val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) completion <- client.complete(conversation) } yield completion result match { case Right(completion) => println(s"Response from ${completion.model}:") println(completion.message.content) case Left(error) => Console.err.println(s"Error: ${error.formatted}") } } ``` ### Run It! ```bash sbt run ``` ### Example Output ``` ✓ Response from mistral: Scala is a statically-typed programming language that combines object-oriented and functional programming paradigms, running on the Java Virtual Machine (JVM). ``` --- ## Step 6: Try Different Models You can easily switch models by changing `model` in the `ollama-local` section (after `ollama pull `): ```hocon ollama-local { provider = "ollama" model = "llama3.2" # or "phi3", "codellama", ... baseUrl = "http://localhost:11434" } ``` If you added the `model = ${?OLLAMA_MODEL}` binding from Step 4, you can switch from the shell instead - `export OLLAMA_MODEL=llama3.2` - because your own `application.conf` reads it. Then run your program again without code changes! --- ## Step 6a: Write Your First LLM4S + Ollama/Llama3.2 App Llama 3.2 is Meta's latest with an impressive 128K context window. Perfect for processing large documents and long conversations. For the code, use the same Scala example from **Step 5** - simply change the model in the `ollama-local` section of `application.conf` (run `ollama pull llama3.2` first): ```hocon ollama-local { provider = "ollama" model = "llama3.2" baseUrl = "http://localhost:11434" } ``` Then run: ```bash sbt run ``` > **Why Llama 3.2?** Latest Llama model with 128K context window. Excellent for RAG applications, long-form content generation, and processing large documents. Available in 1B, 3B, 8B, 70B, and 405B sizes. --- ## Step 6b: Write Your First LLM4S + Ollama/Phi3 App Phi3 is Microsoft's efficient model, even smaller than Phi. Ideal for ultra-low-latency applications and edge deployment. For the code, use the same Scala example from **Step 5** - simply change the model in the `ollama-local` section of `application.conf` (run `ollama pull phi3` first): ```hocon ollama-local { provider = "ollama" model = "phi3" baseUrl = "http://localhost:11434" } ``` Then run: ```bash sbt run ``` > **Why Phi3?** Microsoft's compact model optimized for efficiency. Smaller than Phi (1.4GB) with competitive quality. Perfect for resource-constrained environments and real-time applications requiring minimal latency. --- ## Step 6c: Write Your First LLM4S + Ollama/CodeLlama App CodeLlama is purpose-built for code generation and understanding. Create `HelloCodeLlama.scala`: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model._ import org.llm4s.model.ModelRegistryService object HelloCodeLlama extends App { // Create a conversation asking for code val conversation = Conversation(Seq( SystemMessage("You are an expert Scala developer. Write clean, idiomatic code."), UserMessage("Write a simple Scala function that reverses a list.") )) // Load the default provider section and make the request val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) completion <- client.complete(conversation) } yield completion result match { case Right(completion) => println(s"Code suggestion from ${completion.model}:") println(completion.message.content) case Left(error) => println(s"Error: ${error.formatted}") } } ``` ### Configure for CodeLlama Set the model in the `ollama-local` section of `application.conf` (run `ollama pull codellama` first): ```hocon ollama-local { provider = "ollama" model = "codellama" baseUrl = "http://localhost:11434" } ``` ### Run It! ```bash sbt run ``` ### Example Output ``` ✓ Code suggestion from codellama: def reverseList[T](list: List[T]): List[T] = { list.reverse } // Or for manual reversal: def reverseList[T](list: List[T]): List[T] = { def helper(acc: List[T], remaining: List[T]): List[T] = { if (remaining.isEmpty) acc else helper(remaining.head :: acc, remaining.tail) } helper(Nil, list) } ``` > **Why CodeLlama?** CodeLlama is specialized for code-related tasks. Use it for code generation, refactoring suggestions, and explaining code. 16K context window perfect for larger code files. --- ## Streaming Responses Get real-time token streaming (like ChatGPT): ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model._ import org.llm4s.model.ModelRegistryService object StreamingOllama extends App { val conversation = Conversation(Seq( SystemMessage("You are a concise assistant."), UserMessage("Write a haiku about Scala programming.") )) val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) completion <- client.streamComplete( conversation, CompletionOptions(), chunk => chunk.content.foreach(print) // Print tokens as they arrive ) } yield completion result match { case Right(completion) => println("\n--- Streaming complete! ---") println(s"Total content: ${completion.message.content}") case Left(error) => Console.err.println(s"Error: ${error.formatted}") } } ``` --- ## Tool Calling with Ollama Ollama supports tool calling (function calling) with compatible models, and `llm4s-ollama`'s native client sends the tools and reads the calls (streamed or not) itself - no `/v1` OpenAI-compatible workaround is needed. Only models whose Ollama page lists the **tools** capability can do this (`llama3.1` can; the original `llama3` does not). Sending tools to any other model fails, and the client reports it instead of dropping the tools: ```text Invalid tools: Ollama model 'llama3:latest' does not support tool calling (the server said: registry.ollama.ai/library/llama3:latest does not support tools). Use a model whose Ollama page lists the 'tools' capability, or send no tools. ``` Pull a tool-capable model (`ollama pull llama3.1`) and set `OLLAMA_MODEL` to it: ```scala import org.llm4s.agent.Agent import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService import org.llm4s.toolapi._ import upickle.default._ object OllamaTools extends App { // Define result type case class WeatherResult(forecast: String) implicit val weatherResultRW: ReadWriter[WeatherResult] = macroRW // Define a weather tool with proper schema val weatherSchema = Schema .`object`[Map[String, Any]]("Weather parameters") .withProperty( Schema.property("location", Schema.string("City or location name")) ) val getWeather = ToolBuilder[Map[String, Any], WeatherResult]( "get_weather", "Get the current weather in a location", weatherSchema ).withHandler { extractor => extractor.getString("location").map { location => // Mock implementation WeatherResult(s"Weather in $location: Sunny, 72F") } }.buildSafe() val result = for { weatherTool <- getWeather tools = new ToolRegistry(Seq(weatherTool)) providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) agent <- Agent.builder("weather-agent", client).withTools(tools).build() result <- agent.run("What's the weather in San Francisco?") } yield result result match { case Right(r) => println("Final response:") println(r.answer.getOrElse(s"Run ended: ${r.status}")) case Left(error) => Console.err.println(s"Error: ${error.formatted}") } } ``` --- ## Model Comparison Performance comparison running on Apple M1 Mac: | Model | Speed (tokens/sec) | Quality | Memory | Best Use Case | |-------|-------------------|---------|--------|---------------| | **mistral** | ~40 | ⭐⭐⭐⭐ | 8GB | General purpose, great balance | | **llama2** | ~35 | ⭐⭐⭐⭐ | 8GB | Conversational, creative | | **phi** | ~80 | ⭐⭐⭐ | 4GB | Quick tests, development | | **codellama** | ~35 | ⭐⭐⭐⭐ | 8GB | Code generation | | **neural-chat** | ~40 | ⭐⭐⭐⭐ | 8GB | Dialogue, chat apps | > Performance varies by hardware. These are approximate values on M1 MacBook Pro 16GB RAM. --- ## Configuration Options ### Temperature Control ```scala CompletionOptions( temperature = 0.7, // Higher = more creative (0.0-2.0) maxTokens = Some(1000), topP = 0.9 ) ``` ### Context Length Ollama models have different context windows: - **mistral**: 8k tokens - **llama2**: 4k tokens - **llama3.2**: 128k tokens - **codellama**: 16k tokens - **gemma2**: 8k tokens --- ## Troubleshooting ### "Connection refused" error **Problem**: Ollama server not running **Solution**: ```bash ollama serve ``` ### "Model not found" error **Problem**: Model not pulled **Solution**: ```bash ollama pull mistral ``` ### Slow inference **Problem**: Not enough RAM or CPU **Solutions**: - Use a smaller model: `ollama pull phi` - Close other applications - Check if running on GPU (M-series Mac, CUDA GPU) ### Model comparison ```bash # List all models with sizes ollama list # Delete a model to free space ollama rm llama2 ``` --- ## Running the Examples Try the built-in LLM4S Ollama samples. The samples' own `modules/samples/src/main/resources/application.conf` already defaults to an `ollama-local` section (model `llama3:latest`, server `http://localhost:11434`), and binds `OLLAMA_MODEL` and `OLLAMA_BASE_URL` to override it - those two variables are that file's bindings, not something the library reads. See [Running the samples](configuration#running-the-samples). The default model has to be pulled first; to use a model you already have instead, name it with `OLLAMA_MODEL` (`ollama list` shows what is installed): ```bash # Either pull the samples' default model once... ollama pull llama3 # ...or override the samples' ollama-local section export OLLAMA_MODEL=mistral export OLLAMA_BASE_URL=http://localhost:11434 # Run basic Ollama example sbt "samples/runMain org.llm4s.samples.basic.OllamaExample" # Run Ollama streaming example sbt "samples/runMain org.llm4s.samples.basic.OllamaStreamingExample" # Run Ollama streaming example with raw provider exchange logging sbt "samples/runMain org.llm4s.samples.basic.OllamaStreamingExample /tmp/my-provider-exchanges" # Run tool calling example (works with any provider) sbt "samples/runMain org.llm4s.samples.toolapi.BuiltinToolsExample" ``` The logging-enabled streaming example writes one timestamped JSONL file per run to the directory you provide. Each entry captures the raw request and response exchange, including accumulated streaming payloads for streaming-capable providers. --- ## Production Deployment ### Docker Compose Setup ```yaml # docker-compose.yml version: '3.8' services: ollama: image: ollama/ollama:latest ports: - "11434:11434" volumes: - ollama-data:/root/.ollama mem_limit: 8g llm4s-app: build: . environment: # Read by the app's own application.conf binding: baseUrl = ${?OLLAMA_BASE_URL} - OLLAMA_BASE_URL=http://ollama:11434 depends_on: - ollama volumes: ollama-data: ``` This assumes the app's `ollama-local` section binds the server address, as in Step 4: `baseUrl = ${?OLLAMA_BASE_URL}`. Without the variable the section uses `http://localhost:11434`. ### Pre-pull Models ```bash # Pull models into Docker volume docker-compose exec ollama ollama pull mistral docker-compose exec ollama ollama pull llama2 ``` --- ## Ollama vs Cloud Providers | Feature | Ollama | OpenAI | Anthropic | |---------|--------|--------|-----------| | **Cost** | Free | $0.01-0.06/1K tokens | $0.003-0.015/1K tokens | | **Privacy** | 100% local | Cloud-based | Cloud-based | | **Speed** | Depends on hardware | Fast (API) | Fast (API) | | **Offline** | ✅ Yes | ❌ No | ❌ No | | **Model quality** | Good (7B-70B) | Excellent (GPT-4) | Excellent (Claude) | | **Setup** | 5 minutes | Instant | Instant | **Use Ollama for**: - Development and testing - Privacy-sensitive applications - Cost-conscious deployments - Offline environments **Use cloud providers for**: - Highest quality responses - Scale without hardware limits - Latest model capabilities --- ## Next Steps - [Configuration Guide](configuration) - Named provider sections and advanced settings - [First Example](first-example) - Build more complex agents - [Tool Calling](../examples/) - Add custom tools - [RAG with Ollama](../guide/vector-store) - Retrieval-augmented generation --- ## Resources - [Ollama Official Site](https://ollama.com) - [Ollama GitHub](https://github.com/ollama/ollama) - [Ollama Model Library](https://ollama.com/library) - [LLM4S Discord](https://discord.gg/4uvTPn6qww) - Get help from the community --- > 💡 Pro Tip > > Use Ollama for development and testing, then switch to a cloud provider for production by > pointing `llm4s.providers.provider` at another section - add the provider's module > (e.g. `llm4s-openai`) and a section such as: > > ```hocon > openai-main { > provider = "openai" > model = "gpt-4o" > } > ``` > > then set `provider = "openai-main"` (or `-Dllm4s.providers.provider=openai-main`). Only the > selected section is validated, so `OPENAI_API_KEY` is needed only where `openai-main` is used. > See [Switching providers](configuration#switching-providers). > > Your code stays exactly the same! 🎉 --- --- Source: https://llm4s.org/getting-started/gradle # Gradle Integration Add LLM4S to a Gradle project (Java, Kotlin, or Kotlin/Ktor). --- ## Artifact names LLM4S is published for **Scala 3 only**, and Gradle does **not** resolve Scala cross-version suffixes automatically. The coordinate is `org.llm4s:llm4s-core_3` - you must write the `_3` suffix yourself, unlike sbt where `%%` adds it. > Releases up to `0.3.4` used unprefixed names (`core_3`); from `0.4.0` every artifact carries an `llm4s-` prefix. See the [migration guide](/reference/migration#artifact-coordinate-rename-v040). > From `0.5.0` the provider clients, agent runtime and other features are separate artifacts (`llm4s-openai_3`, `llm4s-anthropic_3`, `llm4s-agent_3`, ...): add the modules you use alongside `llm4s-core_3`, or align them with the [BOM](installation#align-versions-with-the-bom). Up to `0.4.x` everything shipped in `llm4s-core`. The dependency notes below say which version each one applies to. --- ## Kotlin DSL (`build.gradle.kts`) ```kotlin repositories { mavenCentral() } val llm4sVersion = "0.5.1" dependencies { // Scala 3 artifacts - note the explicit _3 suffix implementation("org.llm4s:llm4s-core_3:$llm4sVersion") implementation("org.llm4s:llm4s-openai_3:$llm4sVersion") // the provider module you use } // Pin the Scala 3 library to avoid binary incompatibility from transitive deps. // Match scala3-library_3 only: scala-library stays at 2.13.x and has no 3.x version. configurations.all { resolutionStrategy.eachDependency { if (requested.group == "org.scala-lang" && requested.name == "scala3-library_3") { useVersion("3.7.1") } } } ``` --- ## Groovy DSL (`build.gradle`) ```groovy repositories { mavenCentral() } def llm4sVersion = '0.5.1' dependencies { implementation "org.llm4s:llm4s-core_3:${llm4sVersion}" implementation "org.llm4s:llm4s-openai_3:${llm4sVersion}" // the provider module you use } configurations.all { resolutionStrategy.eachDependency { details -> if (details.requested.group == 'org.scala-lang' && details.requested.name == 'scala3-library_3') { details.useVersion '3.7.1' } } } ``` --- ## Dependency exclusion recipes ### Logback conflict (Spring Boot 3.x, `0.4.x` only) `llm4s-core` `0.4.x` declares `logback-classic` as a compile dependency. Spring Boot 3.2.x defaults to `1.4.x`, and Gradle picks the highest version, so you may see _"multiple SLF4J bindings"_ warnings or a changed log format. From `0.5.0` the split modules depend on `slf4j-api` only (logback is a test dependency), so this recipe is not needed. **Kotlin DSL:** ```kotlin implementation("org.llm4s:llm4s-core_3:$llm4sVersion") { exclude(group = "ch.qos.logback", module = "logback-classic") } ``` **Groovy DSL:** ```groovy implementation("org.llm4s:llm4s-core_3:${llm4sVersion}") { exclude group: 'ch.qos.logback', module: 'logback-classic' } ``` Then declare your preferred Logback version directly: ```kotlin runtimeOnly("ch.qos.logback:logback-classic:1.4.14") // or let Spring BOM manage it ``` --- ### Azure SDK exclusion (non-Azure projects, `0.4.x` only) `llm4s-core` `0.4.x` depends on `com.azure:azure-ai-openai`, which transitively pulls the Azure SDK jars. If you do not use the Azure provider, exclude it: **Kotlin DSL:** ```kotlin implementation("org.llm4s:llm4s-core_3:$llm4sVersion") { exclude(group = "com.azure", module = "azure-ai-openai") } ``` **Groovy DSL:** ```groovy implementation("org.llm4s:llm4s-core_3:${llm4sVersion}") { exclude group: 'com.azure', module: 'azure-ai-openai' } ``` The split modules on `main` have no Azure dependency. --- ### OkHttp and Kotlin standard library alignment The OpenAI and Anthropic SDKs (`openai-java`, `anthropic-java`) both use OkHttp 4.12 and Kotlin standard library 1.9.x. If your build (for example a Ktor project) uses other versions, Gradle resolves to the highest on the classpath. That is normally harmless; if you need to pin them, use a constraint rather than an exclusion, because removing OkHttp from the SDKs breaks them: ```kotlin dependencies { constraints { implementation("com.squareup.okhttp3:okhttp:4.12.0") } } ``` Use `./gradlew dependencyInsight --dependency okhttp --configuration runtimeClasspath` to see which version wins and why. --- ## Spring Boot BOM alignment If you use the Spring Boot dependency-management plugin, align the BOM with your Spring Boot version. With `llm4s-core` `0.4.x`, also exclude the llm4s logback dependency: ```kotlin plugins { id("org.springframework.boot") version "3.2.5" id("io.spring.dependency-management") version "1.1.5" } dependencies { implementation("org.llm4s:llm4s-core_3:$llm4sVersion") { exclude(group = "ch.qos.logback", module = "logback-classic") } // Spring Boot BOM manages logback version automatically } ``` --- ## Ktor setup ```kotlin val llm4sVersion = "0.5.1" val ktorVersion = "2.3.12" dependencies { implementation("io.ktor:ktor-server-core:$ktorVersion") implementation("io.ktor:ktor-server-netty:$ktorVersion") implementation("org.llm4s:llm4s-core_3:$llm4sVersion") implementation("org.llm4s:llm4s-openai_3:$llm4sVersion") // With 0.4.x, exclude logback-classic from llm4s-core here (see above): Ktor manages its own logging backend } ``` --- ## Java sample [`modules/samples/gradle-java`](https://github.com/llm4s/llm4s/tree/main/modules/samples/gradle-java) is a standalone Gradle project that calls llm4s from Java through `llm4s-java-api`: a client, a question, a conversation, and `LlmResult` error handling, with no Scala types in the code. Its README says how to build and run it. CI builds it on every pull request, so it stays current. ## Reference files The `modules/gradle-demo/` directory in the llm4s repository contains: - `build.gradle.kts` - Kotlin DSL reference: `llm4s-core` plus a provider module - `build.gradle` - Groovy DSL equivalent - `settings.gradle.kts` - Minimal settings file These are standalone reference files, not part of the sbt build. Copy them directly into your project as a starting point. The same directory also holds `ConversationTemplates` and `GradleSnippets`, small Scala helpers with their own tests. --- ## Troubleshooting ### `Could not resolve org.llm4s:...` Gradle could not find the artifact. Use the prefixed name and the explicit Scala suffix: `org.llm4s:llm4s-core_3`. There is no Scala 2.13 artifact. ### `Multiple SLF4J bindings found` Two SLF4J implementations are on the classpath. With `llm4s-core` `0.4.x`, exclude `logback-classic` (see [Logback conflict](#logback-conflict-spring-boot-3x-04x-only)) and ensure only one binding is declared. ### `Binary incompatible Scala library versions` Add the `resolutionStrategy` block shown above to pin `org.scala-lang:scala3-library_3` to `3.7.1`. Pin that artifact only: Scala 3 runs on the Scala 2.13 `scala-library` (`2.13.x`), which has no `3.x` release, so a rule that matches the whole `org.scala-lang` group fails with `Could not find org.scala-lang:scala-library:3.7.1`. --- See the [dependency-conflicts reference](/reference/dependency-conflicts) for a full conflict matrix. --- Source: https://llm4s.org/getting-started/testing-guide # Testing LLM4S Applications Learn how to test LLM-powered applications effectively without spending money on API calls. --- ## Testing Strategy Testing LLM applications requires a different approach than traditional software. You need to balance: 1. **Speed** - Tests should run fast 2. **Cost** - Avoid expensive API calls in CI/CD 3. **Determinism** - LLM responses vary, so test behaviors not exact outputs 4. **Coverage** - Test error paths, timeouts, and edge cases --- ## Unit Testing with a Scripted Client For pure logic tests, give your code a scripted client instead of a real one. `llm4s-testkit` provides `ScriptedLLMClient`: it answers from a script you write, records every request your code sent, and never touches the network. Add it as a test dependency: ```scala libraryDependencies += "org.llm4s" %% "llm4s-testkit" % llm4sVersion % Test ``` [Testing with the Testkit](../guide/testing-with-the-testkit) covers the whole API; the examples below show the common cases. ### Canned replies ```scala import org.llm4s.error.NetworkError import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model._ import org.llm4s.testkit.{ Reply, ScriptedLLMClient } import org.llm4s.types.Result import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers class WeatherAgentSpec extends AnyFlatSpec with Matchers { // Example code under test: it takes any LLMClient class WeatherAgent(client: LLMClient) { def run(query: String): Result[Completion] = client.complete(Conversation(Seq(UserMessage(query)))) } "WeatherAgent" should "return the model's answer" in { val client = ScriptedLLMClient.always( Reply .text("The weather in London is 15°C and cloudy.") .withUsage(TokenUsage(promptTokens = 10, completionTokens = 15, totalTokens = 25)) ) val result = new WeatherAgent(client).run("What's the weather in London?") result.map(_.content) shouldBe Right("The weather in London is 15°C and cloudy.") client.callCount shouldBe 1 client.lastCall.flatMap(_.lastUserText) shouldBe Some("What's the weather in London?") } it should "pass a provider error on" in { val client = ScriptedLLMClient.always( Reply.failure(NetworkError("Connection timeout", None, "https://api.example.com")) ) new WeatherAgent(client).run("What's the weather?") match { case Left(_: NetworkError) => succeed case other => fail(s"Expected NetworkError but got: $other") } } } ``` `streamComplete` replays the same reply as chunks (the text in pieces, then any tool calls, then a chunk with the finish reason), so code that streams is tested with the same script. ### A reply chosen by the request `ScriptedLLMClient.respondingTo` picks the reply from the conversation, so the answer does not depend on how many calls came before. A request no rule matches fails with an `UnscriptedCallError` that shows the last message, instead of hanging or throwing: ```scala import org.llm4s.llmconnect.model._ import org.llm4s.testkit.{ Reply, ScriptedLLMClient } def lastUser(conversation: Conversation): String = conversation.messages.collect { case u: UserMessage => u.content }.lastOption.getOrElse("") val client = ScriptedLLMClient.respondingTo { case c if lastUser(c) == "What is 2+2?" => Reply.text("2+2 equals 4") case c if lastUser(c) == "What is the capital of France?" => Reply.text("The capital of France is Paris") } val answer = client.complete(Conversation(Seq(UserMessage("What is 2+2?")))) // Right, "2+2 equals 4" val unexpected = client.complete(Conversation(Seq(UserMessage("Something else")))) // Left(UnscriptedCallError) ``` --- ## Integration Testing with Ollama For integration tests, use Ollama to avoid API costs: ### Setup ```bash # Install Ollama curl -fsSL https://ollama.com/install.sh | sh # Pull a small, fast model ollama pull llama3.2 # Start server ollama serve ``` ### Test Configuration Put a named provider section in your test resources and select it as the default, with `llm4s-ollama` as a test dependency. See [Named provider sections](configuration#named-provider-sections). ```hocon # src/test/resources/application.conf llm4s { providers { provider = "ollama-test" ollama-test { provider = "ollama" model = "llama3.2" baseUrl = "http://localhost:11434" # optional: this is the default } } } ``` ### Integration Test Example ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model._ import org.llm4s.model.ModelRegistryService import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers class LLMIntegrationSpec extends AnyFlatSpec with Matchers { // Only run if Ollama is available def ollamaAvailable: Boolean = { try { val url = new java.net.URL("http://localhost:11434") val connection = url.openConnection() connection.setConnectTimeout(1000) connection.connect() true } catch { case _: Exception => false } } "LLMClient" should "complete basic requests" in { assume(ollamaAvailable, "Ollama server not available") val result = for { config <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(config) response <- client.complete( Conversation(Seq(UserMessage("Say 'hello' and nothing else"))) ) } yield response result match { case Right(response) => response.content.toLowerCase should include("hello") case Left(error) => fail(s"Request failed: $error") } } it should "handle streaming responses" in { assume(ollamaAvailable, "Ollama server not available") var chunks = List.empty[StreamedChunk] val result = for { config <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(config) completion <- client.streamComplete( Conversation(Seq(UserMessage("Count: 1, 2, 3"))), onChunk = chunk => chunks = chunks :+ chunk ) } yield completion result match { case Right(completion) => chunks should not be empty chunks.last.finishReason should be(Some("stop")) case Left(error) => fail(s"Streaming failed: $error") } } } ``` --- ## Testing Error Handling Always test error paths. `Reply.failure` makes the client return the real `LLMError` types, so your code sees exactly what a provider would give it: ```scala import org.llm4s.agent.Agent import org.llm4s.agent.graph.GraphError import org.llm4s.error.{ AuthenticationError, NetworkError, RateLimitError } import org.llm4s.testkit.{ Reply, ScriptedLLMClient } import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers class ErrorHandlingSpec extends AnyFlatSpec with Matchers { private def runWith(reply: Reply) = Agent.builder("test-agent", ScriptedLLMClient.always(reply)).build().flatMap(_.run("test query")) "Agent" should "handle rate limiting" in { // A provider error ends the run as GraphError.NodeFailed, carrying the client's error as its cause runWith(Reply.failure(RateLimitError("Rate limit exceeded"))) match { case Left(GraphError.NodeFailed(_, _, _: RateLimitError)) => succeed case other => fail(s"Expected RateLimitError but got: $other") } } it should "handle authentication errors" in { runWith(Reply.failure(AuthenticationError("openai", "Invalid API key", "401"))) match { case Left(GraphError.NodeFailed(_, _, _: AuthenticationError)) => succeed case other => fail(s"Expected AuthenticationError but got: $other") } } it should "handle network timeouts" in { // No need to sleep: the error a timed-out call returns is what the code under test sees runWith(Reply.failure(NetworkError("Request timeout", None, "https://api.example.com"))) match { case Left(GraphError.NodeFailed(_, _, _: NetworkError)) => succeed case other => fail(s"Expected NetworkError but got: $other") } } } ``` --- ## Testing Tool Calling Script the model's tool call and then its answer with `ScriptedLLMClient.sequence`: the first call gets the first reply, the second call the second. The agent runs your real tool in between, and the recorded calls show what it sent back to the model: ```scala import org.llm4s.agent.Agent import org.llm4s.llmconnect.model._ import org.llm4s.testkit.{ Reply, ScriptedLLMClient } import org.llm4s.toolapi.{ Schema, ToolBuilder, ToolRegistry } import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers class ToolCallingSpec extends AnyFlatSpec with Matchers { "Agent" should "invoke the weather tool with the model's arguments" in { var capturedCity: Option[String] = None // A tool that records the arguments it was called with val weatherTool = ToolBuilder[Map[String, Any], String]( "get_weather", "Get weather for a city", Schema .`object`[Map[String, Any]]("Weather query parameters") .withProperty(Schema.property("city", Schema.string("City name"))) ).withHandler { extractor => extractor.getString("city").map { city => capturedCity = Some(city) s"Weather in $city: 20°C" } }.buildSafe() val client = ScriptedLLMClient.sequence( Reply.toolCall("get_weather", """{"city": "London"}"""), Reply.text("It is 20°C in London.") ) val outcome = for { tool <- weatherTool agent <- Agent.builder("test-agent", client).withTools(new ToolRegistry(Seq(tool))).build() _ <- agent.run("What's the weather in London?") } yield () outcome.isRight shouldBe true capturedCity shouldBe Some("London") client.callCount shouldBe 2 // the second request carries the tool's result back to the model client.calls(1).messages.collect { case t: ToolMessage => t.content } shouldBe Seq("Weather in London: 20°C") } } ``` --- ## Testing RAG Applications Test document retrieval and answer generation separately: ```scala import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers class RAGSpec extends AnyFlatSpec with Matchers { "VectorStore" should "retrieve relevant documents" in { val documents = List( "Scala is a functional programming language", "Python is a dynamically typed language", "Java runs on the JVM" ) // Note: This is conceptual pseudocode showing testing patterns. `InMemoryVectorStore`, `embedder` and // `RAGPipeline` below are illustrative stand-ins, not llm4s classes with these signatures. The llm4s-rag // module has real stores (SQLiteVectorStore, PgVectorStore, QdrantVectorStore). val vectorStore = new InMemoryVectorStore() // Pseudocode - use your vector store documents.foreach(doc => vectorStore.add(doc, embedder.embed(doc))) // embedder is conceptual val results = vectorStore.search("functional programming", topK = 1) results.head should include("Scala") } "RAG pipeline" should "include context in LLM prompt" in { // a scripted client from llm4s-testkit: it records the prompt the pipeline sent val client = ScriptedLLMClient.always(Reply.text("Based on the context, Scala is functional.")) val rag = new RAGPipeline(client, vectorStore, embedder) rag.query("What is Scala?") client.lastCall.flatMap(_.lastUserText).get should include("context") client.lastCall.flatMap(_.lastUserText).get should include("functional") } } ``` --- ## CI/CD Testing Strategy ### Fast CI Pipeline ```yaml # .github/workflows/test.yml name: Test on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Scala uses: olafurpg/setup-scala@v14 with: java-version: 21 - name: Install Ollama run: | curl -fsSL https://ollama.com/install.sh | sh ollama serve & sleep 5 ollama pull llama3.2 - name: Run unit tests (fast) run: sbt "testOnly *UnitSpec" - name: Run integration tests (with Ollama) # The provider comes from src/test/resources/application.conf (the ollama-test section # above); no environment variable selects it. run: sbt "testOnly *IntegrationSpec" # Skip expensive tests in CI - name: Run full test suite run: sbt test if: github.event_name == 'push' && github.ref == 'refs/heads/main' ``` ### Test Categorization ```scala // Tag tests by speed/cost import org.scalatest.Tag import org.scalatest.flatspec.AnyFlatSpec object UnitTest extends Tag("UnitTest") object IntegrationTest extends Tag("IntegrationTest") object ExpensiveTest extends Tag("ExpensiveTest") class FastSpec extends AnyFlatSpec { "Fast unit test" should "run in CI" taggedAs UnitTest in { // Mock-based test } } class SlowSpec extends AnyFlatSpec { "Expensive test" should "run manually" taggedAs ExpensiveTest in { // Uses real OpenAI API } } ``` Run specific test categories: ```bash # Fast tests only sbt "testOnly * -- -n UnitTest" # Everything except expensive tests sbt "testOnly * -- -l ExpensiveTest" ``` --- ## Best Practices 1. ✅ **Mock by default**: Use mock clients for unit tests 2. ✅ **Ollama for integration**: Free and fast enough for CI 3. ✅ **Test behaviors, not outputs**: LLM responses vary, so test that tools are called, documents are retrieved, etc. 4. ✅ **Use deterministic models when possible**: Set temperature=0 for more predictable outputs 5. ✅ **Separate concerns**: Test tool logic independently from LLM integration 6. ✅ **Tag expensive tests**: Don't run them in every CI build 7. ✅ **Use smaller models in CI**: llama3.2 is fast and free via Ollama --- ## Example Test Suite Structure ``` src/test/scala/ ├── unit/ │ ├── ToolSpec.scala # Pure logic tests (mocked) │ ├── ConfigSpec.scala # Configuration parsing │ └── ErrorHandlingSpec.scala # Error path tests ├── integration/ │ ├── LLMClientSpec.scala # Real LLM calls (Ollama) │ ├── AgentSpec.scala # End-to-end agent tests │ └── RAGSpec.scala # RAG pipeline tests └── resources/ └── application.conf # Test config (Ollama) ``` --- ## Next Steps - [Configuration Guide](configuration) - Configure test environments - [Examples](/examples/) - See tests in action - [CI/CD Best Practices](/advanced/ci-cd) - Production testing strategies --- ## Additional Resources - [ScalaTest Documentation](https://www.scalatest.org/) - [Ollama Models](https://ollama.com/library) - Available test models - [Testing Guide](/reference/testing-guide) - Advanced testing patterns --- Source: https://llm4s.org/getting-started/scala-cli # Scala CLI Quick Start A first LLM call from a single file, with no sbt project. --- [Scala CLI](https://scala-cli.virtuslab.org/) runs a Scala file with its dependencies declared in the file itself, so you can try LLM4S without creating a build. This page is the shortest path from nothing to an answer; when you want a real project, use the [starter kit](installation#quick-start-with-the-starter-kit) or [add LLM4S to your build](installation#add-llm4s-to-your-project). ## Before you start - **Scala CLI**, installed as described on its [install page](https://scala-cli.virtuslab.org/install). - **A JDK** is optional: the script's `//> using jvm 21` line makes Scala CLI use JDK 21, and download it the first time if you do not have it. LLM4S needs JDK 21 (see [Installation](installation#prerequisites)). - **An API key** for OpenAI, or a local [Ollama](ollama-quickstart) (no key). ## The files Make a directory with these files. The same files are in [`modules/samples/scala-cli`](https://github.com/llm4s/llm4s/tree/main/modules/samples/scala-cli). ``` hello-llm4s/ hello.scala logback.xml resources/application.conf resources/ollama.conf (only for the no-key Ollama run) ``` `hello.scala`: (not compile-checked) ```scala //> using scala 3.7.1 //> using jvm 21 //> using dep org.llm4s::llm4s-core:0.5.1 //> using dep org.llm4s::llm4s-openai:0.5.1 //> using dep org.llm4s::llm4s-ollama:0.5.1 //> using dep ch.qos.logback:logback-classic:1.5.34 //> using resourceDir resources //> using javaOpt -Dlogback.configurationFile=logback.xml import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model.{ Conversation, UserMessage } import org.llm4s.model.ModelRegistryService @main def hello(): Unit = { val answer = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) completion <- client.complete(Conversation(Seq(UserMessage("In one sentence, what is Scala?")))) } yield completion.content answer.fold(error => println(s"Error: ${error.formatted}"), text => println(text)) } ``` `resources/application.conf` names the provider the script uses: the section that `provider` names. The [Configuration guide](configuration#named-provider-sections) covers the other providers: ```hocon llm4s { providers { provider = "openai-main" openai-main { provider = "openai" model = "gpt-4o-mini" apiKey = ${?OPENAI_API_KEY} } } } ``` `resources/ollama.conf` is the no-key alternative, a local [Ollama](ollama-quickstart) (it needs `ollama serve` and `ollama pull llama3`). It is a separate file so that each run reads only its own provider: ```hocon llm4s { providers { provider = "ollama-local" ollama-local { provider = "ollama" model = "llama3" baseUrl = "http://localhost:11434" baseUrl = ${?OLLAMA_BASE_URL} } } } ``` `logback.xml` keeps the library's own logging out of your first answer (without it, logback prints every debug message): ```xml System.err %d{HH:mm:ss} %-5level %logger{20} - %msg%n ``` ## Run it Run from inside the directory: the `logback.xml` path in the script is relative to where you run it. ```bash cd hello-llm4s export OPENAI_API_KEY=sk-... scala-cli run . ``` With a local Ollama instead, no key needed (`-Dconfig.resource` tells the configuration library to read `ollama.conf` in place of `application.conf`): ```bash scala-cli run . --java-opt -Dconfig.resource=ollama.conf ``` The first run downloads the dependencies, which takes a while. You should see one sentence, for example: ``` Scala is a language that blends object-oriented and functional programming on the JVM. ``` The wording depends on the model. ## What the script does 1. `Llm4sConfig.defaultProvider()` reads the section that `llm4s.providers.provider` names. This is the one place configuration is read. 2. `LLMConnect.getClient` builds the client for that provider. 3. `client.complete` sends the conversation. Every step returns a `Result`, so the `for` stops at the first error and the last line prints it instead of throwing. ## If something goes wrong **`Error: ConfigurationError: Provider 'openai-main' (provider = openai) is missing required fields:`** followed by `- apiKey: set OPENAI_API_KEY, or set apiKey under llm4s.providers.openai-main in application.conf`: the key is not set in the shell that runs `scala-cli`. Check `echo $OPENAI_API_KEY`, or use the Ollama command above. **`Error: NetworkError: POST http://localhost:11434/api/chat: connection failed`** (the Ollama run): no Ollama server is listening. Start it with `ollama serve`, or point `OLLAMA_BASE_URL` at the one you have. **A wall of `DEBUG` lines before the answer**: `logback.xml` was not found. Run from the directory that contains it, and check the name and the `javaOpt` line. **The version**: the `//> using dep` lines pin the release. Change the number on every `org.llm4s` line together to move to another one: the modules are released at the same version. ## The dependencies `llm4s-core` holds no provider client, so the script adds the provider modules it uses, `llm4s-openai` and `llm4s-ollama`; keep only the one you use. `llm4s-core` brings no logging backend either, so the script adds logback (see the [migration guide](/reference/migration#llm4s-no-longer-brings-a-logging-backend)). The logback line is a Java dependency (a single colon, unlike the `::` Scala form); without a backend SLF4J prints a single "no SLF4J providers were found" warning and `logback.xml` has no effect. The `apiKey = ${?OPENAI_API_KEY}` line in the section is optional: `llm4s-openai` binds `OPENAI_API_KEY` itself, and a section's own `apiKey` takes precedence over it. Up to 0.4.x, `llm4s-core` included the provider clients and a logging backend, so a script for 0.4.x needs only the `llm4s-core` line, and the `apiKey` line in the section. ## Next steps - [First Example](first-example): the same call in a project, with conversation context, tools and streaming - [Configuration](configuration): all providers and how keys are found - [Ollama Quick Start](ollama-quickstart): local models --- Source: https://llm4s.org/getting-started/maven # Maven Quick Start A complete `pom.xml` that compiles and runs a first call from Java, with `llm4s-java-api`. --- The project is in [`modules/samples/maven-java`](https://github.com/llm4s/llm4s/tree/main/modules/samples/maven-java), and CI compiles it against the library on every pull request. For Gradle, see [Gradle Integration](gradle); for the Java API itself, the [Java guide](/guide/java). ## Before you start - **JDK 21 or newer** and **Maven 3.9 or newer**. `llm4s-java-api` is JDK 21 bytecode, so the project sets `maven.compiler.release` to 21. - **An API key** for OpenAI, or a local [Ollama](ollama-quickstart) (no key). ## The coordinate LLM4S is published for **Scala 3 only**, and Maven does **not** add the Scala cross-version suffix: the artifact is `org.llm4s:llm4s-java-api_3`, and you write the `_3` yourself. Its POM brings `llm4s-core`, `llm4s-agent` and the provider modules, so it is the one llm4s dependency a Java project needs. ## The pom.xml ```xml 4.0.0 org.llm4s.samples llm4s-maven-sample 1 21 UTF-8 0.5.1 3.7.1 org.scala-lang scala3-library_3 ${scala3.version} com.fasterxml.jackson jackson-bom 2.19.4 pom import org.jetbrains.kotlin kotlin-bom 1.9.10 pom import com.typesafe config 1.4.9 org.slf4j slf4j-api 2.0.17 com.google.errorprone error_prone_annotations 2.50.0 io.swagger.core.v3 swagger-annotations 2.2.50 org.llm4s llm4s-java-api_3 ${llm4s.version} ch.qos.logback logback-classic 1.5.34 runtime org.apache.maven.plugins maven-enforcer-plugin 3.5.0 enforce-convergence enforce org.codehaus.mojo exec-maven-plugin 3.5.0 org.llm4s.samples.HelloLLM4S ``` What each part is for: - **`llm4s-java-api_3`** is the library. Change `llm4s.version` to move to another release; every llm4s artifact is released at the same version. - **`logback-classic`** is the logging backend. llm4s logs through SLF4J and ships none; without one SLF4J prints a single "no SLF4J providers were found" warning and drops the logs. - **`scala3-library_3`** in `dependencyManagement` pins the Scala 3 library. Several llm4s modules may ask for different micro-versions, and Maven's nearest-wins rule would pick one silently. Pin `scala3-library_3` by name, never the whole `org.scala-lang` group: Scala 3 runs on the Scala 2.13 `scala-library`, which has no 3.x release. The [dependency conflicts reference](/reference/dependency-conflicts) has the matrix. - **`maven-enforcer-plugin` with `dependencyConvergence`** fails the build when two dependencies need different versions of one library, so a conflict shows up at build time and not as a `NoSuchMethodError` at run time. When it fails, it names the library and both paths: add a `dependencyManagement` entry for it, as for `scala3-library_3`, or an exclusion. - **The other `dependencyManagement` entries** settle the conflicts the enforcer finds today: the OpenAI and Anthropic SDKs ask for different Jackson, Kotlin, error-prone and Swagger versions, and pureconfig for an older Typesafe Config and SLF4J than llm4s. Each pin is the newest version any of them asks for; the Jackson and Kotlin BOMs pin every artifact of those families at once. - **`exec-maven-plugin`** gives `mvn exec:java`, which runs the `main` class. ## The code and the configuration `src/main/java/org/llm4s/samples/HelloLLM4S.java` creates a client from the default provider, asks one question and prints the answer or the error. Every call returns an `LlmResult`, so there is no Scala `Either` in the code: ```java LlmResult created = Llm4s.createDefaultClient(); if (created.isFailure()) { System.err.println("Could not create a client: " + created.getError().getMessage()); return 1; } try (JLlmClient client = created.get()) { LlmResult answer = client.complete("In one sentence, what is Scala?"); // ... print answer.get(), or answer.getError().getMessage() } ``` `src/main/resources/application.conf` names the provider, as a named section; the [Configuration guide](configuration#named-provider-sections) covers the others: ```hocon llm4s { providers { provider = "openai-main" openai-main { provider = "openai" model = "gpt-4o-mini" } } } ``` ## Run it ```bash export OPENAI_API_KEY=sk-... mvn -q compile exec:java ``` You should see one sentence; the wording depends on the model. With a local Ollama, change the section to `provider = "ollama"` with a `model` you have pulled, as in the [Ollama quick start](ollama-quickstart). ## If something goes wrong **`Could not find artifact org.llm4s:llm4s-java-api:jar`** (no `_3`): add the suffix, `llm4s-java-api_3`. **`Could not create a client: ... apiKey: set OPENAI_API_KEY`**: the key is not set in the shell that runs `mvn`. **`Rule 0: ...DependencyConvergence failed`**: see the enforcer entry above. **`class file has wrong version 65.0`** or similar: Maven is running on a JDK older than 21. Check `mvn -v`. ## Next steps - [Java guide](/guide/java): conversations, options, agents and errors from Java - [Gradle Integration](gradle): the same project in Gradle - [Quarkus](/guide/quarkus): a Quarkus application built the same way, with a CDI producer and a REST endpoint - [Configuration](configuration): all providers and how keys are found --- Source: https://llm4s.org/getting-started/jbang # JBang Quick Start A first LLM call from one Java file, with no build tool and no project. --- [JBang](https://www.jbang.dev/) runs a Java file with its dependencies declared in the file itself, so you can try LLM4S from Java without creating a project. `llm4s-java-api` is on Maven Central from `0.5.0`. When you want a real project, use [Maven](maven) or [Gradle](gradle). ## Before you start - **JBang**, installed as described on its [download page](https://www.jbang.dev/download/). - **JDK 21 or newer**: the file's `//JAVA 21` line makes JBang use JDK 21, and download it the first time if you do not have it. LLM4S needs JDK 21 (see [Installation](installation#prerequisites)). - **A local [Ollama](ollama-quickstart)**, which needs no key: run `ollama serve` and `ollama pull llama3`. ## The files Make a directory with these three files. The same files are in [`modules/samples/jbang`](https://github.com/llm4s/llm4s/tree/main/modules/samples/jbang). `HelloLlm4s.java`: ```java ///usr/bin/env jbang "$0" "$@" ; exit $? //JAVA 21 //DEPS org.llm4s:llm4s-java-api_3:0.5.1 //DEPS ch.qos.logback:logback-classic:1.5.34 //FILES application.conf //FILES logback.xml import org.llm4s.javaapi.JLlmClient; import org.llm4s.javaapi.Llm4s; import org.llm4s.javaapi.LlmResult; public class HelloLlm4s { public static void main(String[] args) { LlmResult created = Llm4s.createDefaultClient(); if (created.isFailure()) { System.err.println("Could not create a client: " + created.getError().getMessage()); System.exit(1); } try (JLlmClient client = created.get()) { LlmResult answer = client.complete("In one sentence, what is Scala?"); if (answer.isFailure()) { System.err.println("The call failed: " + answer.getError().getMessage()); System.exit(1); } System.out.println(answer.get()); } catch (Exception e) { System.err.println("Closing the client failed: " + e.getMessage()); System.exit(1); } } } ``` The `//DEPS` line is the whole dependency: Maven does not add the Scala suffix, so the artifact is `llm4s-java-api_3`, and its POM brings `llm4s-core`, `llm4s-agent` and the provider modules, including Ollama. The second `//DEPS` line is the logging backend: llm4s logs through SLF4J and ships none, so without it SLF4J prints a "no SLF4J providers were found" warning and drops the logs. `//FILES` puts a file on the classpath. `application.conf` names the provider, as a named section; the [Configuration guide](configuration#named-provider-sections) covers the others: ```hocon llm4s { providers { provider = "ollama-local" ollama-local { provider = "ollama" model = "llama3" baseUrl = "http://localhost:11434" baseUrl = ${?OLLAMA_BASE_URL} } } } ``` `logback.xml` keeps the library's own logging out of your first answer (without it, logback prints every debug message): ```xml System.err %d{HH:mm:ss} %-5level %logger{20} - %msg%n ``` ## Run it From the directory that holds the three files: ```bash jbang HelloLlm4s.java ``` The first run downloads the dependencies, which takes a while. You should see one sentence; the wording depends on the model. To compile without running, use `jbang build HelloLlm4s.java`. For OpenAI instead of Ollama, change the section in `application.conf` to `provider = "openai"` with `model = "gpt-4o-mini"`, and set `OPENAI_API_KEY` in the shell. ## If something goes wrong **`Could not create a client: ...`**: the message names what is missing, for example a provider key. Check `application.conf` and the environment. **`The call failed: ... connection failed`**: no Ollama server is listening. Start it with `ollama serve`, or point `OLLAMA_BASE_URL` at the one you have. **A wall of `DEBUG` lines before the answer**: `logback.xml` is not beside the file, or `//FILES logback.xml` is missing. **The version**: change the number in the `//DEPS org.llm4s` line to move to another release. ## Trying the code on `main` `llm4s-java-api` ships with `0.5.0`. Before a change is released, publish it to your local Maven repository from a checkout of this repository, as the [Gradle sample](https://github.com/llm4s/llm4s/tree/main/modules/samples/gradle-java) does: ```bash sbt 'set ThisBuild / version := "0.1.0-SNAPSHOT"' publishM2 ``` Then change the `//DEPS` line to version `0.1.0-SNAPSHOT` and add `//REPOS mavenlocal,mavencentral` above it. ## Next steps - [Maven Quick Start](maven): the same call in a `pom.xml` - [Java guide](/guide/java): conversations, options, agents and errors from Java - [Configuration](configuration): all providers and how keys are found --- Source: https://llm4s.org/getting-started/troubleshooting # First-Run Troubleshooting The problems people most often meet when an LLM4S program does not work the first time, each with the symptom, the cause and the fix. The message texts below are the ones the code produces (the class that builds each is named), so you can search this page for the text you see. Each of these messages ends with a link to its entry here, such as `See https://llm4s.org/getting-started/troubleshooting#missing-api-key`, and a configuration error also with the doctor's command. The anchors (`#missing-api-key`) are fixed: they do not change when an entry is renumbered or its heading reworded. Two tools help before you read on: - **The doctor** checks your JDK, configuration, default provider, provider module, API key and (with `--local`) a local model server in one run, and says what to change. See [Doctor](/reference/doctor). - **The rest of the reference** has [Troubleshooting / FAQ](/reference/troubleshooting) for later problems (agents, streaming, RAG, tracing) and [Configuration: Troubleshooting](configuration#troubleshooting). ## Contents 1. [The API key is missing](#missing-api-key) 2. [No default provider configured](#no-default-provider) 3. [Configured provider was not found](#provider-not-found) 4. [Provider is not registered](#provider-not-registered) 5. [application.conf is not picked up](#application-conf-not-loaded) 6. [LLM_MODEL has no effect](#llm-model-ignored) 7. [The code does not compile: no ModelRegistryService](#no-model-registry-service) 8. [Ollama is not running](#ollama-not-running) 9. [Ollama does not have the model](#ollama-model-missing) 10. [UnsupportedClassVersionError or a JDK that is too old](#jdk-too-old) 11. [Authentication failed (401 or 403)](#authentication-failed) 12. [Rate limited (429)](#rate-limited) 13. [Timeouts](#timeouts) 14. [Model not found (404)](#model-not-found) 15. [Behind a proxy or TLS inspection](#corporate-network) ## 1. The API key is missing {#missing-api-key} **Symptom** (a `ConfigurationError`, from `Llm4sConfig.defaultProvider()` or `Llm4sConfig.provider(...)`): ```text Provider 'openai-main' (provider = openai) is missing required fields: - apiKey: set OPENAI_API_KEY, or set apiKey under llm4s.providers.openai-main in application.conf See https://llm4s.org/getting-started/troubleshooting#missing-api-key Run the doctor to check your whole setup: sbt "configPolicy/runMain org.llm4s.configpolicy.DoctorCli --config /path/to/application.conf" ``` An embeddings block without a key fails the same way, with `Missing openai embeddings apiKey: set OPENAI_API_KEY, ...`. **Cause.** Neither the vendor's variable nor an `apiKey` in the section is available to the JVM. The variable named is the one the provider's module binds (`llm4s-openai` binds `OPENAI_API_KEY`); a section that sets its own `apiKey` uses that instead. A variable exported in one terminal is not seen by an IDE run configuration started elsewhere. **Fix.** Export the variable in the shell that starts the JVM (or set it in the IDE run configuration), or set `apiKey` in the section. The error names the section it is about: if it is not the one you meant, check `llm4s.providers.provider`. See [API keys](configuration#api-keys). ## 2. No default provider configured {#no-default-provider} **Symptom** (a `ConfigurationError`): ```text No default provider configured under llm4s.providers.provider See https://llm4s.org/getting-started/troubleshooting#no-default-provider Run the doctor to check your whole setup: sbt "configPolicy/runMain org.llm4s.configpolicy.DoctorCli --config /path/to/application.conf" ``` **Cause.** `Llm4sConfig.defaultProvider()` loads the section that `llm4s.providers.provider` names, and the key is not set, usually because no `application.conf` defines it. **Fix.** Add the key to your `application.conf` (see [Named provider sections](configuration#named-provider-sections)), or load a section by name with `Llm4sConfig.provider("")`. ## 3. Configured provider was not found {#provider-not-found} **Symptom** (a `ConfigurationError`): ```text Configured provider 'openai-main' was not found See https://llm4s.org/getting-started/troubleshooting#provider-not-found Run the doctor to check your whole setup: sbt "configPolicy/runMain org.llm4s.configpolicy.DoctorCli --config /path/to/application.conf" ``` **Cause.** `llm4s.providers.provider` (or the name you passed to `Llm4sConfig.provider(...)`) names a section that does not exist, for example through a typo, or a `-Dllm4s.providers.provider=...` override. **Fix.** Make the name match a section under `llm4s.providers` exactly. ## 4. Provider is not registered {#provider-not-registered} **Symptom** (a `ConfigurationError`, from `LLMConnect.getClient`): ```text Provider 'openai' is not registered. Registered providers: none. If you expected 'openai', add the dependency that supplies it, or register it explicitly with ProviderRegistry.of(...). Discovery scanned 0 modules; 0 failed. See https://llm4s.org/getting-started/troubleshooting#provider-not-registered Run the doctor to check your whole setup: sbt "configPolicy/runMain org.llm4s.configpolicy.DoctorCli --config /path/to/application.conf" ``` When the id came from a config section, the message also says which key it came from. The first line ends with a sentence about classpath discovery, such as `Discovery scanned 0 modules; 0 failed.` **Cause.** Since 0.5.0 `llm4s-core` ships no provider: each one is its own module (`llm4s-openai`, `llm4s-anthropic`, `llm4s-ollama`, ...). Either the module is not a dependency of your build, or you build a fat jar and the `META-INF/services` entries of the modules were dropped or overwritten when the jars were merged (`Discovery scanned 0 modules` in the message points to this). **Fix.** Add the provider's module next to `llm4s-core` (the table in [Named provider sections](configuration#named-provider-sections) lists them), or, for a fat jar, merge the `META-INF/services` files in your assembly settings or register the provider explicitly with `ProviderRegistry.of(...)` / `.withProvider(...)`. ## 5. application.conf is not picked up {#application-conf-not-loaded} **Symptom.** `Llm4sConfig` fails as if your sections were not there (problem 2 or 3 above), although you wrote them, or it cannot read the block at all (a `ConfigurationError`): ```text Failed to load raw providers config via PureConfig: See https://llm4s.org/getting-started/troubleshooting#application-conf-not-loaded Run the doctor to check your whole setup: sbt "configPolicy/runMain org.llm4s.configpolicy.DoctorCli --config /path/to/application.conf" ``` A section that cannot be read on its own fails only a load of that section, with `Failed to read llm4s.providers.: ...` and the same two lines. **Cause.** The usual reasons are that the file is not on the runtime classpath (it must be in `src/main/resources/`, not at the project root), that `-Dconfig.file` or `-Dconfig.resource` points at another file, that the HOCON has a syntax error, or that a `-D` system property overrides the key. Precedence is system properties, then `application.conf`, then each module's `reference.conf`. **Fix.** Check the file's location and run the [doctor](/reference/doctor) with `--config `, which reports an unreadable configuration and where it is broken. See [How configuration is loaded](configuration#how-configuration-is-loaded). ## 6. LLM_MODEL has no effect {#llm-model-ignored} **Symptom.** You set `LLM_MODEL=openai/gpt-4o` (as older guides and blog posts say) and the program still uses another model, or fails with problem 2. **Cause.** Nothing in the library reads `LLM_MODEL` any more. Providers are named sections in `application.conf`. **Fix.** Define a section and select it, as in [Named provider sections](configuration#named-provider-sections). The chat-tui sample is the exception: it reads `LLM_MODEL` itself. ## 7. The code does not compile: no ModelRegistryService {#no-model-registry-service} **Symptom.** A Scala compile error at a `LLMConnect.getClient(...)` call: the compiler reports that no given instance of `org.llm4s.model.ModelRegistryService` was found. **Cause.** `LLMConnect.getClient` takes a `ModelRegistryService` (and a `ProviderRegistry`) as `using` parameters. The `ProviderRegistry` has a default; the model registry service you provide. **Fix.** Load it with `Llm4sConfig.modelRegistryService()` and bind it as a `given` before calling `getClient`, as in [First Example](first-example). ## 8. Ollama is not running {#ollama-not-running} **Symptom** (a `NetworkError` from a chat call, or the doctor's "Local server" step failing): ```text POST http://localhost:11434/api/chat: connection failed: ConnectException. Start the server with `ollama serve`, and check the section's baseUrl. See https://llm4s.org/getting-started/troubleshooting#ollama-not-running ``` The doctor's step says `GET http://localhost:11434/api/tags: connection failed: ConnectException` instead. **Cause.** Nothing is listening at the section's `baseUrl` (default `http://localhost:11434`). The text after `connection failed:` is the Java exception's message, or its class name when it has none. `unknown host: ...` instead means the host name in `baseUrl` does not resolve. **Fix.** Start the server with `ollama serve` (on macOS and Windows the app may need to be started), and check the section's `baseUrl` (the samples bind it to `OLLAMA_BASE_URL`). `curl http://localhost:11434` answers `Ollama is running` when it is up. See [Ollama Quick Start](ollama-quickstart). ## 9. Ollama does not have the model {#ollama-model-missing} **Symptom** (a `ServiceError` with HTTP 404 from a chat call): ```text Service error from ollama: model "llama3" not found, try pulling it first (HTTP 404). Pull it with `ollama pull llama3`, or set the section's model to one `ollama list` shows. See https://llm4s.org/getting-started/troubleshooting#ollama-model-missing ``` or, from the doctor run with `--local`: ```text http://localhost:11434 answered, but it does not have 'llama3' (it has: ...) fix: run `ollama pull llama3`, or set the model to one it has ``` **Cause.** The section's `model` is not among the models the server has downloaded. The text before `(HTTP 404)` is the server's own. **Fix.** Pull it (`ollama pull `), or change `model` in the section to a model `ollama list` shows. The samples' default is `llama3:latest`, which needs `ollama pull llama3` once, or `OLLAMA_MODEL` set to another. ## 10. UnsupportedClassVersionError or a JDK that is too old {#jdk-too-old} **Symptom.** The JVM refuses to load LLM4S classes with `java.lang.UnsupportedClassVersionError` (that text is the JVM's, not LLM4S's), or the doctor reports: ```text Java is too old: LLM4S needs 21 or newer ``` **Cause.** Every LLM4S artifact is compiled for JDK 21 and needs at least that. `java -version`, `JAVA_HOME`, and the JDK your IDE and sbt use can each name a different one. **Fix.** Run sbt, your application and your IDE with JDK 21 or newer (set `JAVA_HOME`, or choose the JDK in the IDE). See the [v1 scope](/reference/v1-scope) for the support statement. ## 11. Authentication failed (401 or 403) {#authentication-failed} **Symptom** (an `AuthenticationError`): ```text Authentication failed for openai: . Check that the key is this provider's, and that its project or organisation has access to the model. See https://llm4s.org/getting-started/troubleshooting#authentication-failed ``` A provider that answers 403 through an SDK that does not type it (Anthropic) gives a `ServiceError`, `Service error from anthropic: ... (HTTP 403)`, with the same sentence and link. **Cause.** The provider answered with HTTP 401 or 403: the key is wrong, expired, revoked or has no access to the model. The most common slip is a key from another vendor: a section whose own `apiKey` binds a variable holding a different provider's key wins over the shared `OPENAI_API_KEY`. **Fix.** Check which key the section uses: the INFO line `llm4s.providers.: API key from ` says where it came from (never the value). See [Provider mismatch](configuration#problem-provider-mismatch). ## 12. Rate limited (429) {#rate-limited} **Symptom** (a `RateLimitError`): ```text Rate limited by openai. Retry after 20s. Lower the request concurrency, or wrap the client in ReliableClient to retry and throttle. See https://llm4s.org/getting-started/troubleshooting#rate-limited ``` `Retry after 20s` appears only when the provider sent a `Retry-After` header. **Cause.** The provider answered HTTP 429: too many requests or tokens for your plan. **Fix.** Wait for the delay and retry, slow the caller down, or raise the plan's limit. A `RateLimitError` is a recoverable error, and `ReliableClient` can retry and throttle for you (see the [fallback cookbook](/examples/cookbook/fallback) and [Rate limiting / 429 errors](configuration#problem-rate-limiting--429-errors)). ## 13. Timeouts {#timeouts} **Symptom** (a `TimeoutError`; these are the messages of the JDK-based HTTP client): ```text POST https://example.com/v1/chat/completions: request timed out after 2 minutes POST https://example.com/v1/chat/completions: connection timed out after 10 seconds ``` **Cause.** The response did not begin in time (`request timed out`), or the TCP connection could not be made (`connection timed out`), often a wrong `baseUrl`, a proxy or a firewall. A slow local model on a CPU can also exceed the default. **Fix.** Check the endpoint is reachable - behind an outbound proxy, configure it ([problem 15](#corporate-network)) - and if the model is just slow raise the timeout in the section's `timeouts` block (`timeouts { request = 5m, stream = 20m }`). The defaults differ by provider, and the OpenAI, Azure, Requesty and Anthropic clients use their SDK's own timeouts, which retry; see [Timeouts](configuration#timeouts). ## 14. Model not found (404) {#model-not-found} **Symptom** (a `ServiceError` with HTTP 404): ```text Service error from openai: The model `gpt-5-mini-typo` does not exist or you do not have access to it. (HTTP 404). Check the section's model name and baseUrl; Llm4sConfig.listModels() lists the models the provider offers. See https://llm4s.org/getting-started/troubleshooting#model-not-found ``` **Cause.** The provider has no model of that name, or the section's `baseUrl` points at the wrong path, so the endpoint itself is not found. A local Ollama that has not pulled the model answers this way too; see [problem 9](#ollama-model-missing). **Fix.** Check the section's `model` against the provider's model list: `Llm4sConfig.listModels()` lists the models of the default section's provider, and `Llm4sConfig.listModels("")` those of a named section. If the model is right, check `baseUrl` (an OpenAI-compatible server's usually ends in `/v1`). ## 15. Behind a proxy or TLS inspection {#corporate-network} **Symptom** (a `TimeoutError` or a `NetworkError`): ```text POST https://api.openai.com/v1/chat/completions: connection timed out after 10 seconds POST https://api.openai.com/v1/chat/completions: I/O error: PKIX path building failed: unable to find valid certification path to requested target ``` **Cause.** The network reaches the internet only through an outbound HTTP proxy, which llm4s is not using, or a proxy inspects TLS and re-signs traffic with a corporate CA the JVM does not trust. A proxy that needs a login answers `407 Proxy Authentication Required`. **Fix.** Set `HTTPS_PROXY` (and `NO_PROXY` for hosts reached directly), or the `llm4s.http.proxy` block, with `LLM4S_PROXY_USER` / `LLM4S_PROXY_PASSWORD` for an authenticating proxy; point `llm4s.http.tls.trustStore` (or `LLM4S_TRUSTSTORE`) at a PKCS12 or JKS store holding the corporate CA. Every provider client honours them; see [Corporate networks](configuration#corporate-networks-a-proxy-and-a-custom-ca), which also lists what is not covered (PAC files, Kerberos/NTLM). --- Still stuck? Run the [doctor](/reference/doctor), then ask on [Discord](https://discord.gg/4uvTPn6qww) with the full message. --- Source: https://llm4s.org/getting-started/scala-2-13 # Using LLM4S from Scala 2.13 Scala 2.13: call-only, via the TASTy reader. A Spark job, a Play application or an Akka or Pekko service can call LLM4S's Scala 3 artifacts today. 1. TOC --- LLM4S is published for Scala 3 only: there is no `_2.13` artifact, and a cross-build is not planned before 1.0 ([#874](https://github.com/llm4s/llm4s/issues/874)). But the Scala 2.13 compiler can read Scala 3 libraries through its TASTy reader, and a project on Scala 2.13.16 or later that does so can *call* LLM4S: load a provider, complete and stream, build options, run an agent and run tools. What it cannot do is *extend* LLM4S with Scala types (define a tool with `ToolBuilder`, ask for structured output into a case class, implement `LLMClient`); for the first two, `llm4s-java-api` is the route, and it works from Scala 2.13 through the TASTy reader as well. These are not promises written from reading the code: each statement below is a probe, a small Scala 2.13 program that CI compiles and runs against the current build, on Scala 2.13.16 and the latest 2.13, and that fails the build when its outcome changes ([what is checked](#how-this-is-checked)). ## Setup (not compile-checked) ```scala // build.sbt of a Scala 2.13 project, which reads LLM4S through the TASTy reader scalaVersion := "2.13.16" // or later: the TASTy reader of 2.13.16 reads the Scala 3.7 artifacts scalacOptions += "-Ytasty-reader" libraryDependencies ++= Seq( ("org.llm4s" %% "llm4s-core" % "0.5.1").cross(CrossVersion.for2_13Use3), ("org.llm4s" %% "llm4s-ollama" % "0.5.1").cross(CrossVersion.for2_13Use3), // one provider module per provider ("org.llm4s" %% "llm4s-agent" % "0.5.1").cross(CrossVersion.for2_13Use3) // if you run agents ) ``` `CrossVersion.for2_13Use3` makes `%%` pick the `_3` artifacts. Two rules keep the classpath consistent: - **Do not add the `_2.13` build of a library LLM4S uses as `_3`** (cats, uPickle, pureconfig) next to it: the two builds share class names, and whichever loads first wins. Let LLM4S bring the `_3` ones. - **`scala-library` must be 2.13.16 or later at run time**, the version LLM4S's Scala 3 standard library is built on. The [Spark guide](../guide/spark) covers runtimes that pin an older one. ## Calling LLM4S From Scala 2.13, pass LLM4S's `using` parameters as an explicit second argument list: (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model.{Conversation, UserMessage} import org.llm4s.llmconnect.spi.ProviderRegistry val registry = ProviderRegistry.default val reply = for { models <- Llm4sConfig.modelRegistryService() config <- Llm4sConfig.defaultProvider()(registry) client <- LLMConnect.getClient(config)(models, registry) completion <- client.complete(Conversation(Seq(UserMessage("Hi")))) } yield completion.content ``` Configuration is the same `application.conf` as from Scala 3 ([Configuration](configuration)). ## What works | From Scala 2.13 | Works | Probe | |---|---|---| | `Conversation`, messages, `Result` | yes | `A_conversation` | | `Llm4sConfig` + `LLMConnect.getClient` + `complete` | yes | `B_config_client` | | `streamComplete` with a Scala 2.13 callback | yes | `C_streaming` | | `CompletionOptions` builders, `ReasoningEffort` and the other enumerations | yes | `D_options_enums` | | Running tools through a `ToolRegistry` you were given | yes | `E2_tool_registry` | | `Agent.builder(...).build()`, `run`, `continueConversation` | yes | `G_agent` | | Defining a tool with `llm4s-java-api`'s `JTool.builder`, and an agent calling it | yes | `J_java_api_tool` | | Structured output into a Java record with `llm4s-java-api`'s `JLlmClient.complete(prompt, Record.class)` | yes | `K_java_api_structured` | | Defining a tool with `ToolBuilder` | **no** | `E_tools` | | `completeStructured[A]` | **no** | `F_structured` | | Implementing `LLMClient` | **no** | `H_subclass_client` | | Naming an agent id: `ThreadId("...")` | **no** | `I_opaque_ids` | ## The four caveats and their workarounds ### 1. Defining a tool with `ToolBuilder` `ToolBuilder` takes a uPickle `ReadWriter` for the tool's result, and the TASTy reader cannot read uPickle's Scala 3 type classes, whether summoned or written by hand. Use `llm4s-java-api`'s tool builder, which needs no codec, and the agent it builds: (not compile-checked) ```scala // add ("org.llm4s" %% "llm4s-java-api" % ...).cross(CrossVersion.for2_13Use3) import org.llm4s.javaapi.{JTool, JToolArgs, Llm4s} val add = JTool .builder("add", "Adds two integers") .param("a", classOf[Integer], "the first") .param("b", classOf[Integer], "the second") .handler((args: JToolArgs) => Integer.valueOf(args.getInt("a") + args.getInt("b"))) .build() .get() val client = Llm4s.createDefaultClient().get() val answer = Llm4s.createAgentWithTools(client, java.util.List.of(add)).get().run("What is 2 + 3?") ``` A Scala 2.13 lambda converts to the Java `JToolHandler` interface. Tools that LLM4S already defines (the built-in tools of `llm4s-agent-tools`) are ordinary values, and an agent or a `ToolRegistry` runs them from Scala 2.13 as it runs any other. ### 2. Structured output with `completeStructured` `completeStructured[A]` takes a uPickle `Reader[A]`, for the same reason out of reach. `llm4s-java-api`'s `JLlmClient.complete(prompt, Record.class)` derives the schema from a **Java record** and reads the answer into it. A Scala case class is not a record, so declare the record in a Java source file of the same project, which sbt compiles alongside the Scala: ```java // src/main/java/City.java public record City(String name, String country, int population) {} ``` (not compile-checked) ```scala import org.llm4s.javaapi.Llm4s val city: City = Llm4s.createDefaultClient().get().complete("The largest city in Japan", classOf[City]).get() ``` `JTool` and `JLlmClient.complete(prompt, Record.class)` are on `main` and not yet in a release: until the next one, [build LLM4S locally](installation#unreleased-changes-main) to use them. ### 3. Implementing `LLMClient` Implementing the trait means the compiler checks every member, including `completeStructured`'s `Reader`, so a Scala 2.13 class cannot extend it. Calling the members of a client LLM4S gives you works. For a test double, use `llm4s-java-api`'s `FakeLlmClient` (`FakeLlmClient.replying("first", "second").client()`), which the structured-output probe uses from Scala 2.13. A custom provider is written in Scala 3, in its own module ([writing a provider](../guide/writing-a-provider)), and called from Scala 2.13 like the built-in ones. ### 4. Naming an agent id The agent runtime's ids (`ThreadId`, `RunId`, ...) are top-level opaque types whose companions the TASTy reader of Scala 2.13 does not see, so `ThreadId("saved-1")` does not compile. Pass back the ids LLM4S returns (`result.threadId`, or `agent.continueConversation(result, ...)`); through `llm4s-java-api` the ids are plain `String`s (`JAgentResult.threadId()`). ## How this is checked The probes live in [`docs/design/scala-2.13-interop/probes`](https://github.com/llm4s/llm4s/tree/main/docs/design/scala-2.13-interop/probes), and [`expected.md`](https://github.com/llm4s/llm4s/blob/main/docs/design/scala-2.13-interop/expected.md) beside them holds each one's expected outcome. The *Scala 2.13 probes* workflow publishes the current build to the runner's local repository, runs every probe on Scala 2.13.16 and the latest Scala 2.13 release, and fails when an outcome differs from the table in either direction: a change that breaks one of the calls above fails, and so does one that fixes a caveat until this page and the table say so. It runs nightly and on every change to the frozen modules, `llm4s-java-api` or the probes. To run them yourself against a release, with [Scala CLI](https://scala-cli.virtuslab.org/): ```bash cd docs/design/scala-2.13-interop ./run-probes.sh # the latest release, on 2.13.16 and the latest 2.13 ``` The measurements and the reasons behind each caveat are in the design record, [Calling LLM4S from Scala 2.13](https://github.com/llm4s/llm4s/blob/main/docs/design/scala-2.13-interop.md). --- Source: https://llm4s.org/getting-started/mill # Mill Quick Start A first LLM call, and a test of it that needs no API key, in a [Mill](https://mill-build.org) project. --- Mill 1.x writes its build in Scala 3, in a `build.mill` file, and LLM4S is published for Scala 3, so a Mill project depends on LLM4S like on any other Scala library. This page is a complete project: the build, a program that asks a model one question, its configuration, and a unit test that runs without a model. ## Before you start - **Nothing to install for Mill**: the project carries Mill's launcher script, `./mill`, which downloads the Mill version the build names the first time it runs. It downloads JDK 21 as well (the `mill-jvm-version` line), the JDK LLM4S needs (see [Installation](installation#prerequisites)). - **An API key** for OpenAI, or a local [Ollama](ollama-quickstart) (no key). The test needs neither. ## The files Make a directory with these files. The same files are in [`modules/samples/mill`](https://github.com/llm4s/llm4s/tree/main/modules/samples/mill), where CI builds them and runs the test against the latest release on Maven Central. ``` hello-llm4s/ mill (the launcher, see below) build.mill app/src/Hello.scala app/resources/application.conf app/resources/logback.xml app/test/src/HelloTests.scala ``` Mill's `ScalaModule` reads sources from `/src`, resources (the files put on the classpath) from `/resources`, and a test module's sources from `/test/src`. The module here is `app`. ### The launcher Download it from Maven Central and make it executable (on Windows, download `mill-dist-1.3.1-mill.bat` from the same directory and save it as `mill.bat`): ```bash curl -fL https://repo1.maven.org/maven2/com/lihaoyi/mill-dist/1.3.1/mill-dist-1.3.1-mill.sh -o mill chmod +x mill ``` ### build.mill (not compile-checked) ```scala //| mill-version: 1.3.1 //| mill-jvm-version: 21 package build import mill.*, scalalib.* val llm4sVersion = "0.5.1" object app extends ScalaModule { def scalaVersion = "3.7.1" def mvnDeps = Seq( mvn"org.llm4s::llm4s-core:$llm4sVersion", mvn"org.llm4s::llm4s-openai:$llm4sVersion", mvn"org.llm4s::llm4s-ollama:$llm4sVersion", mvn"ch.qos.logback:logback-classic:1.5.34" ) object test extends ScalaTests, TestModule.Munit { def mvnDeps = Seq( mvn"org.scalameta::munit:1.3.6", mvn"org.llm4s::llm4s-testkit:$llm4sVersion" ) } } ``` - The `//|` lines at the top are Mill's header: the Mill version, and the JDK Mill runs and compiles with. - `::` in a `mvn"..."` dependency adds the Scala binary suffix (`llm4s-core_3`), as `%%` does in sbt; a Java library such as logback takes a single `:`. - `llm4s-core` holds no provider client, so the build adds the provider modules the program can use, `llm4s-openai` and `llm4s-ollama`; keep only the one you use. It brings no logging backend either, so the build adds logback, and `app/resources/logback.xml` configures it. - Every llm4s module is released at the same version, so `llm4sVersion` names it once. ### app/src/Hello.scala (not compile-checked) ```scala package hello import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.{ LLMClient, LLMConnect } import org.llm4s.llmconnect.model.{ Conversation, UserMessage } import org.llm4s.model.ModelRegistryService import org.llm4s.types.Result object Hello { /** Asks `client` one question. Taking the client as a parameter is what lets the test script it. */ def ask(client: LLMClient, question: String): Result[String] = client.complete(Conversation(Seq(UserMessage(question)))).map(_.content) def main(args: Array[String]): Unit = { val answer = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) text <- ask(client, "In one sentence, what is Scala?") } yield text answer.fold(error => println(s"Error: ${error.formatted}"), text => println(text)) } } ``` 1. `Llm4sConfig.defaultProvider()` reads the section of `application.conf` that `llm4s.providers.provider` names. This is the one place configuration is read. 2. `LLMConnect.getClient` builds the client for that provider. 3. `client.complete` sends the conversation. Every step returns a `Result`, so the `for` stops at the first error and the last line prints it instead of throwing. ### app/resources/application.conf Mill puts `app/resources` on the classpath, which is where the configuration library looks for `application.conf`. `provider` names the section the program uses; the [Configuration guide](configuration#named-provider-sections) covers the other providers: ```hocon llm4s { providers { provider = "openai-main" provider = ${?LLM4S_PROVIDER} openai-main { provider = "openai" model = "gpt-4o-mini" } ollama-local { provider = "ollama" model = "llama3" baseUrl = "http://localhost:11434" baseUrl = ${?OLLAMA_BASE_URL} } } } ``` The OpenAI section needs no `apiKey` line: `llm4s-openai` reads `OPENAI_API_KEY` itself. The second `provider` line lets the environment pick the other section (`LLM4S_PROVIDER=ollama-local`). Only the section being loaded is checked, so the Ollama run does not need an OpenAI key. ### app/resources/logback.xml It keeps the library's own logging out of your first answer (without it, logback prints every debug message): ```xml System.err %d{HH:mm:ss} %-5level %logger{20} - %msg%n ``` ### app/test/src/HelloTests.scala `ScriptedLLMClient`, from `llm4s-testkit`, is an `LLMClient` that answers from a script, so the test runs with no model, no network and no key (see [Testing with the testkit](../guide/testing-with-the-testkit)): (not compile-checked) ```scala package hello import org.llm4s.testkit.{ Reply, ScriptedLLMClient } class HelloTests extends munit.FunSuite { test("ask returns the model's answer") { val client = ScriptedLLMClient.always(Reply.text("Scala is a JVM language.")) assertEquals(Hello.ask(client, "What is Scala?"), Right("Scala is a JVM language.")) } test("ask sends the question as the user's message") { val client = ScriptedLLMClient.always(Reply.text("ok")) Hello.ask(client, "What is Scala?") assertEquals(client.callCount, 1) assertEquals(client.lastCall.flatMap(_.lastUserText), Some("What is Scala?")) } test("ask returns an error rather than throwing") { val client = ScriptedLLMClient.sequence() // no reply scripted: the call fails assert(Hello.ask(client, "What is Scala?").isLeft) } } ``` ## Run it ```bash cd hello-llm4s ./mill app.test # no key, no model export OPENAI_API_KEY=sk-... ./mill app.run LLM4S_PROVIDER=ollama-local ./mill app.run # a local Ollama instead, no key ``` The first run downloads Mill, the JDK and the dependencies, which takes a while. `app.run` should print one sentence, for example: ``` Scala is a language that blends object-oriented and functional programming on the JVM. ``` The wording depends on the model. Mill runs tasks in a background daemon that it starts on the first command and reuses, which makes later commands fast. A program that reads from the terminal (a chat loop, say) works under `./mill app.run` too: since Mill 1.1 the daemon passes the terminal through. Older guides use `./mill -i app.run` for this; in Mill 1.x `-i` (`--interactive`) only means `--no-daemon`, which runs without the daemon and is what you want in CI. ## If something goes wrong **`Error: ConfigurationError: Provider 'openai-main' (provider = openai) is missing required fields:`** followed by `- apiKey: set OPENAI_API_KEY, or set apiKey under llm4s.providers.openai-main in application.conf`: the key is not set in the shell that runs `./mill`. Check `echo $OPENAI_API_KEY`, or use the Ollama command above. **`Error: NetworkError: POST http://localhost:11434/api/chat: connection failed`** (the Ollama run): no Ollama server is listening. Start it with `ollama serve`, or point `OLLAMA_BASE_URL` at the one you have. **A wall of `DEBUG` lines before the answer**: `logback.xml` is not on the classpath. Check that it is in `app/resources`. More first-run problems are in [First-Run Troubleshooting](troubleshooting). ## Optional extras ### Other llm4s modules Add them to `mvnDeps` with the same `llm4sVersion`. For example `llm4s-schema-derivation`, which derives the JSON schema of a structured answer from a case class with `derives SchemaOf` (see [Derived Schemas](../guide/derived-schemas)): (not compile-checked) ```scala mvn"org.llm4s::llm4s-schema-derivation:$llm4sVersion" ``` [Installation](installation#optional-dependencies) lists every module; its sbt lines translate one for one (`"org.llm4s" %% "llm4s-rag" % llm4sVersion` is `mvn"org.llm4s::llm4s-rag:$llm4sVersion"`). ### Aligning versions with llm4s-bom Instead of `llm4sVersion`, Mill can import the bill of materials, `llm4s-bom`, and take every llm4s module's version from it. The BOM is a Maven artifact with no Scala suffix, so it takes a single `:`; the modules then name no version: (not compile-checked) ```scala object app extends ScalaModule { def scalaVersion = "3.7.1" def bomMvnDeps = Seq(mvn"org.llm4s:llm4s-bom:0.5.1") def mvnDeps = Seq( mvn"org.llm4s::llm4s-core", mvn"org.llm4s::llm4s-openai" ) } ``` See [Align versions with the BOM](installation#align-versions-with-the-bom) for what the BOM does and does not pin. ## Next steps - [First Example](first-example): conversation context, tools and streaming - [Configuration](configuration): all providers and how keys are found - [Testing with the testkit](../guide/testing-with-the-testkit): scripting tool calls and multi-turn conversations - [Ollama Quick Start](ollama-quickstart): local models --- Source: https://llm4s.org/ai-assistants # Using llm4s with AI coding assistants Give your coding assistant the current llm4s documentation and the ten rules it most often gets wrong. --- Assistants were trained mostly on older llm4s, or on none. They invent `Llm4sConfig` overloads that no longer exist, read `LLM_MODEL`, call a provider from `llm4s-core` (which ships none), throw instead of returning `Result`, or reach for `ConfigFactory.load()`. Two things fix most of it: the documentation in a form assistants read, and a short rules file in your project. ## The documentation, as files Both files are generated from this site's pages whenever the site is built, so they are never hand-edited copies. | File | Contents | |------|----------| | [`/llms.txt`](/llms.txt) | A curated index ([llmstxt.org](https://llmstxt.org/)): the release version, then every page of the getting-started pages, the user guide, the cookbook and the Java and Kotlin guides, in navigation order, each with its one-line description. | | [`/llms-full.txt`](/llms-full.txt) | The full text of those same pages in one file. Every Scala block comes from a page whose snippets are compiled against the release (`DocSnippets`); a block on any other page is preceded by `(not compile-checked)`. | | [`/llm4s-rules.md`](/llm4s-rules.md) | The rules block below, as a file to drop into your project. | `/llms.txt` follows the latest release. A project pinned to an older minor release can use the copy kept for it, `/llms//llms.txt` (with `llms-full.txt` and `llm4s-rules.md` beside it), for the latest minor and the one before it. ## Point your assistant at llm4s Put the rules file in the place your tool reads, and, for a tool that fetches URLs, give it `llms.txt`. | Tool | Where the rules go | The documentation | |------|--------------------|-------------------| | Claude Code | `CLAUDE.md` (or `AGENTS.md`, imported from it) | Ask it to read `https://llm4s.org/llms-full.txt`, or add the URL to your `CLAUDE.md` | | Codex | `AGENTS.md` | Name `https://llm4s.org/llms.txt` in `AGENTS.md` | | GitHub Copilot | `.github/copilot-instructions.md` | Reference `https://llm4s.org/llms.txt` in a chat with `#fetch` | | Cursor | a rule in `.cursor/rules/` | Add `https://llm4s.org/llms-full.txt` under Settings, Indexing and Docs | | Metals with an MCP client | `AGENTS.md` or your client's instructions file | Whatever your client offers for URLs | For example, to start a project with the rules: ```bash curl -fsSL https://llm4s.org/llm4s-rules.md >> AGENTS.md ``` ## The rules This block is the content of `/llm4s-rules.md`. Its Scala examples are compiled with the rest of the documentation, so they match the current release. ## Rules for writing llm4s code llm4s is Scala 3 on JDK 21. Follow these when you write or change code that uses it. ### 1. Return `Result[A]`; never throw `Result[A]` is `Either[LLMError, A]`. A failing call is a `Left`, so compose with `for`, `map` and `fold`, and convert a `Try` with `.toResult`. ```scala import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ Conversation, UserMessage } import org.llm4s.types.Result def answer(client: LLMClient, question: String): Result[String] = client.complete(Conversation(Seq(UserMessage(question)))).map(_.content) ``` ### 2. Load configuration with `Llm4sConfig`, once, at the edge of the application Core code receives the client it needs as a parameter. ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.{ LLMClient, LLMConnect } import org.llm4s.model.ModelRegistryService import org.llm4s.types.Result def startup(): Result[LLMClient] = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) } yield client ``` ### 3. Name the provider in `application.conf`, not in code A chat provider is a named section under `llm4s.providers`, and `llm4s.providers.provider` names the default. Load the default with `Llm4sConfig.defaultProvider()`. ```hocon llm4s { providers { provider = "openai-main" openai-main { provider = "openai" model = "gpt-4o-mini" } } } ``` ### 4. A provider is its own dependency `llm4s-core` ships no provider. Add the module of each provider you call (`llm4s-openai`, `llm4s-anthropic`, `llm4s-gemini`, `llm4s-ollama`, `llm4s-openai-compatible`, ...) next to it, and other features likewise (`llm4s-agent`, `llm4s-rag`, `llm4s-agent-tools`). ### 5. Bring a `ModelRegistryService` into scope as a `given` Do it before `LLMConnect.getClient`, as in rule 2. ### 6. Never read `LLM_MODEL` Nothing in the library reads it. The model is the `model` key of the provider section. ### 7. Never use `sys.env`, `System.getenv`, `ConfigFactory.load()` or `ConfigSource.default` An API key comes from `llm4s.credentials..apiKey`, which each provider module binds to the vendor's usual variable (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, ...), or from the section's own `apiKey`. Environment variables reach the configuration only through `${?VAR}` in a `.conf` file. ### 8. Build `CompletionOptions` with its `with` methods Start from `CompletionOptions()` and chain `.withTemperature(...)`, `.withMaxTokens(...)`. Do not call `new` or `.copy`. ```scala import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ CompletionOptions, Conversation } def ask(client: LLMClient, conversation: Conversation) = { val options = CompletionOptions().withTemperature(0.2).withMaxTokens(512) client.complete(conversation, options) } ``` ### 9. A duration is a `FiniteDuration`, not a number of milliseconds Write `30.seconds`, never `timeoutMs = 30000`. ```scala import scala.concurrent.duration._ import org.llm4s.toolapi.ToolExecutionConfig val toolConfig = ToolExecutionConfig.default.withTimeout(30.seconds) ``` ### 10. Use the current artifact names and the latest release version The group is `org.llm4s`, every artifact is `llm4s-` and Scala 3 only (`%%` in sbt, the `_3` suffix elsewhere); `llm4s-core` is not `core`, and the Java API is `llm4s-java-api`. A project using several modules can import the `llm4s-bom`. Take the version from the [installation page](/getting-started/installation), not from memory. ## Does it help? The rules and `llms.txt` target the failures above, which are the ones that make a first attempt not compile. The five prompts to test them with are: "make a first call to OpenAI", "add a tool", "stream a reply", "RAG over a folder" and "call llm4s from Java". Give each to your assistant with and without the rules and `llms.txt`, and count the outputs that compile. --- Source: https://llm4s.org/guide/ # User Guide Comprehensive guides for LLM4S features. They describe the current release, 0.5.1 (Scala 3.7.x, JDK 21); new to LLM4S? Start with [Getting Started](/getting-started/), and upgrading from 0.4.x, with [Upgrading from 0.4.1 to 0.5.0](/migrations/0-4-1-to-0-5-0). Since 0.5.0 each feature below is its own module next to `llm4s-core` (the agent runtime in `llm4s-agent`, RAG in `llm4s-rag`, each provider in its own module, and so on); each guide names the module it needs, and [Installation](/getting-started/installation#optional-dependencies) lists them all. ## Available Guides ### Fundamentals - **[Basic Usage](basic-usage)** - Get started with LLM calls, client creation, and error handling - **[Providers](providers)** - Overview of supported providers and how to configure them - **[Java](java)** - Call a model from Java with `llm4s-java-api`: a client, a conversation and failures you can read without Scala - **[Java RAG](java-rag)** - Ingest documents and ask questions from Java with `llm4s-java-rag`: answers with their sources - **[Java Reactor](java-reactor)** - Stream a reply as a Reactor `Flux` with `llm4s-reactor`, for Spring WebFlux and other reactive pipelines - **[Spring Boot](spring-boot)** - `llm4s-spring-boot-starter`: properties, an `LLM4STemplate` bean, asynchronous calls and a health indicator - **[Kotlin](/getting-started/installation#for-kotlin-coroutines-and-flow)** - `llm4s-kotlin-api`: suspend functions and `Flow`s over the Java API (Experimental; not on Maven Central for 0.5.1 yet, so built locally for now) - **[Error Handling](error-handling)** - Work with `Result[A]` and `LLMError`: pattern matching, for-comprehensions, every error type, recovery and testing - **[Testing with the Testkit](testing-with-the-testkit)** - Script an `LLMClient` in your tests: canned replies, tool calls, injected errors, recorded requests - **[Structured Output](structured-output)** - `completeStructured`: typed replies from a schema, which providers enforce it, and what a bad reply looks like - **[Writing a Provider](writing-a-provider)** - Publish your own provider module against the `llm4s-core` SPI - **[Comparing LLM4S](comparison)** - A sourced comparison with LangChain4j, Spring AI, Koog and Embabel, and a [migration guide](migrating-from-langchain4j) - **[Caching](caching)** - Cache embeddings (exact) and model responses (semantic): configuration, keys, TTL, eviction and what the cache reports - **[Multimodal Content](multimodal)** - Send images and documents in a user message or a tool result, and check before the request whether the provider takes them - **[JSON Libraries](json-libraries)** - Use circe, play-json or zio-json with LLM4S: converting at the boundary, structured output and tools ### Agent Framework - **[Agents Overview](agents/)** - Build LLM-powered agents with tools, guardrails, and multi-turn conversations - **[Guardrails](agents/guardrails)** - Input/output validation for safety and quality - **[Memory System](agents/memory)** - Persistent context and knowledge across conversations - **[Handoffs](agents/handoffs)** - Agent-to-agent delegation for specialist routing - **[Streaming Events](agents/streaming)** - Real-time execution feedback for responsive UIs - **[Context Window Pruning](context-window-pruning)** - Keep a long conversation within the model's context window - **[Built-in Tools](builtin-tools)** - Calculator, date and time, UUID, JSON, files, HTTP, shell and web search tools, the bundles that hold them, and what each one can do - **[Build Your Own Tool](custom-tools)** - Write a tool with `ToolBuilder`: the `Schema` DSL, reading arguments, returning errors, registering it with an agent and testing it - **[Derived Schemas](derived-schemas)** - Write the type once: `llm4s-schema-derivation` builds the `completeStructured` schema from a case class with `derives`. ### RAG & Semantic Search - **[Embeddings](embeddings)** - Choose a provider and model, create an `EmbeddingClient`, embed documents and queries, and keep dimensions in step with the store - **[Vector Store](vector-store)** - Complete RAG toolkit for semantic search and retrieval - **Vector Backends**: SQLite (in-memory/file), PostgreSQL/pgvector, Qdrant - **Keyword Backends**: SQLite FTS5, PostgreSQL native full-text search - **Hybrid Search**: BM25 keyword + vector fusion with RRF strategy - **Reranking**: Cohere cross-encoder for result refinement - **Document Chunking**: Sentence-aware + simple chunking strategies - **[Query Transformers](rag-query-transformers)** - Rewrite the user's query before retrieval: `LLMQueryRewriter`, `IdentityTransformer`, chaining, error behaviour and custom transformers - **[RAG Evaluation](rag-evaluation)** - Measure and improve RAG quality - **RAGAS Metrics**: Faithfulness, answer relevancy, context precision/recall - **Benchmarking Harness**: Compare chunking, fusion, and embedding strategies - **Optimization Workflow**: Data-driven RAG improvement - **[Permission-Based RAG](permission-based-rag)** - Enterprise access control for RAG - **Hierarchical Collections**: Organize documents by tenant, team, or project - **Two-Level Permissions**: Collection-level `queryableBy` + document-level `readableBy` - **Pattern Queries**: `*`, `path/*`, `path/**` for flexible collection scoping - **Principal Management**: Map users/groups to efficient integer IDs ### Multimodal Capabilities - **[Image Generation](image-generation)** - Generate images with DALL-E and other providers - **[Speech](speech)** - Speech-to-text (STT) and text-to-speech (TTS) - **[Jev decision model](jev)** - Ask TypeSafe's Jev typed questions (Noul, Choice, Score) and get typed answers: a decision client, not a chat model ### Effect Systems - **[cats-effect](cats-effect)** - `LLMClientIO` and `AgentIO` for cats-effect `IO` and fs2 streaming - **[ZIO](zio)** - `LLMClientZ` and `AgentZ` for ZIO 2 and ZIO Streams - **[Apache Pekko](pekko)** - `LLMClientPekko` and `AgentPekko` for Pekko Streams - **[Spark and Databricks](spark)** - enrich a DataFrame from a Spark job: a client per partition, `ReliableClient`, errors as data ### Java - **[Java Threading and Cancellation](java-threading-and-cancellation)** - Which thread a call blocks, sharing one client, virtual threads, interrupts and timeouts for `llm4s-java-api` ### Observability - **[Monitoring](observability/)** - Production monitoring for LLM4S applications - **Tracing**: Langfuse and OpenTelemetry integration - **Logging**: Structured JSON logging with SLF4J/Logback - **[Provider Exchange Logging](observability/provider-exchange-logging)**: Capture raw provider requests and responses for debugging - **Health Checks**: Startup validation and readiness probes - **Cost Monitoring**: Token usage tracking and budget awareness ## Feature Coverage via Examples For features not yet documented as dedicated guides, see our **[Examples Gallery](/examples/)** which includes 69 working examples: | Feature | Examples Section | |---------|------------------| | Basic LLM Calling | [Basic Examples](/examples/#basic-examples) | | Multi-Turn Conversations | [Context Management Examples](/examples/#context-management-examples) | | Agent Framework | [Agent Examples](/examples/#agent-examples) | | Tool Calling | [Tool Examples](/examples/#tool-examples) | | Guardrails & Safety | [Guardrails Examples](/examples/#guardrails-examples) | | Agent Handoffs | [Handoff Examples](/examples/#handoff-examples) | | Memory System | [Memory Examples](/examples/#memory-examples) | | Streaming | [Streaming Examples](/examples/#streaming-examples) | | Embeddings & RAG | [Embeddings Examples](/examples/#embeddings-examples) | | MCP Integration | [MCP Examples](/examples/#mcp-examples) | | Observability | [Monitoring Guide](observability/) | ## Design Documents For in-depth technical documentation, see our [design documents](/reference/#design-documents): - [Agent Framework Roadmap](https://github.com/llm4s/llm4s/blob/main/docs/design/agent-framework-roadmap.md) - [Phase 1.1: Conversations](https://github.com/llm4s/llm4s/blob/main/docs/design/phase-1.1-functional-conversation-management.md) - [Phase 1.2: Guardrails](https://github.com/llm4s/llm4s/blob/main/docs/design/phase-1.2-guardrails-framework.md) - [Phase 1.3: Handoffs](https://github.com/llm4s/llm4s/blob/main/docs/design/phase-1.3-handoff-mechanism.md) ## Getting Help - Browse [examples](/examples/) for working code samples - Check the [Scaladoc](/scaladoc/) for API documentation - Join our [Discord community](https://discord.gg/4uvTPn6qww) for support --- Source: https://llm4s.org/guide/basic-usage # Basic Usage Guide Learn the fundamentals of LLM4S: creating clients, making LLM calls, and handling results. --- ## Getting Started with LLM Calls LLM4S makes it simple to integrate Large Language Models into your Scala applications. The core workflow is: 1. **Configure** a named provider section in your `application.conf` 2. **Create** an LLM client 3. **Send** messages to the LLM 4. **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: ```scala 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`: ```hocon 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: ```bash 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](../getting-started/configuration#named-provider-sections) and [API keys](../getting-started/configuration#api-keys). ### Multi-Provider Pattern LLM4S resolves whichever named provider you configure as the default: ```scala // 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("")`. See [Switching providers](../getting-started/configuration#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("")`. To adjust the generation settings of one call, pass `CompletionOptions`: ```scala 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]`: ```scala 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 `for` comprehensions ### Pattern Matching on Results The most common approach is pattern matching: ```scala 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: ```scala 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](error-handling) 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: ```scala 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: ```scala 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: ```scala val userMsg = UserMessage("What is Scala?") val response = client.complete(Conversation(Seq(userMsg))) ``` ### Assistant Messages Include assistant responses for multi-turn conversations: ```scala 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: ```scala val conversation = Conversation(Seq( SystemMessage("You are a Scala expert. Answer concisely."), UserMessage("What is a monad?") )) val response = client.complete(conversation) ``` --- ## 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}`: ```hocon llm4s { providers { provider = "openai-main" openai-main { provider = "openai" model = "gpt-4o" } } } ``` ```bash 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: ```bash 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](../getting-started/configuration#environment-variables-llm4s-reads). ### System Properties JVM system properties override `application.conf`, key for key: ```bash 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: ```scala 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: ```scala 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: ```scala 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 own `apiKey` binds, 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](/MODEL_METADATA.md) for available models - Some models may not be available in your region or account ### "Configuration not found" - Ensure `application.conf` (in `src/main/resources`) has a section under `llm4s.providers` and that `llm4s.providers.provider` names it - `LLM_MODEL` is not read - Ensure the vendor's variable (or the one the section's own `apiKey` binds) is exported in the shell that starts the JVM - Check the error names the section you meant to load: `defaultProvider()` loads the one `llm4s.providers.provider` names, 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](../getting-started/configuration#timeouts)) --- Source: https://llm4s.org/guide/providers # Providers Guide LLM4S supports multiple LLM providers out of the box. Choose your provider, configure it, and start building. --- ## Supported Providers LLM4S supports these LLM providers, plus any endpoint that speaks the OpenAI chat-completions API. What each one supports with LLM4S - chat, streaming, tool calling, embeddings, API key or workload identity, live smoke suites - is in the [compatibility matrix](../reference/compatibility-matrix), generated from the provider modules' code and checked in CI, so this table does not repeat it: | Provider | `provider` id | Type | Models | Setup | |----------|---------------|------|--------|-------| | **OpenAI** | `openai` | Cloud | GPT and o-series models | Medium | | **Anthropic** | `anthropic` | Cloud | Claude models | Medium | | **Google Gemini** | `gemini` | Cloud | Gemini models, with a free tier | Medium | | **Google Vertex AI** | `vertexai` | Cloud Enterprise | Gemini models in your own GCP project | Medium | | **Azure OpenAI** | `azure` | Cloud Enterprise | OpenAI models in your Azure deployment | Hard | | **DeepSeek** | `deepseek` | Cloud | DeepSeek models | Easy | | **OpenRouter** | `openrouter` | Cloud gateway | Many vendors' models behind one key | Easy | | **Z.ai** | `zai` | Cloud | GLM models | Easy | | **OpenAI-compatible** | `openai-compatible` | Cloud or local | Groq, Together, Fireworks, NVIDIA NIM, OrcaRouter; vLLM, LM Studio, llama.cpp - config only ([recipes](#openai-compatible-endpoints)) | Easy | | **Mistral** | `mistral` | Cloud | Mistral and Magistral models | Easy | | **Cohere** | `cohere` | Cloud | Command models | Easy | | **xAI** | `xai` | Cloud | Grok models | Easy | | **Ollama** | `ollama` | Local | Open models on your machine, no API key | Easy | | **IBM watsonx.ai** | `watsonx` | Cloud Enterprise | Granite, Llama and Mistral models under IBM governance | Medium | | **Amazon Bedrock** | `bedrock` | Cloud Enterprise | Models on AWS, through the Converse API (`llm4s-bedrock`) | Medium | | **Requesty** | `requesty` | Cloud gateway | Many vendors' models behind one key | Easy | Missing a vendor? See [Writing a Provider](writing-a-provider) to publish your own provider module. --- ## Provider Selection ### How It Works Each provider you use is a **named section** under `llm4s.providers` in your own `application.conf`. `llm4s.providers.provider` names the default section, which `Llm4sConfig.defaultProvider()` loads; `Llm4sConfig.provider("")` loads any section by name. A section's API key comes from its own `apiKey` when it sets one, and otherwise from its vendor's shared key, `llm4s.credentials..apiKey`, which each provider module binds to the vendor's conventional variable (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, ...). So with the variable exported, a section needs only `provider` and `model`. The library reads no `LLM_MODEL`. See [API keys](../getting-started/configuration#api-keys) for the full table and for a second account. ```hocon # src/main/resources/application.conf llm4s { providers { provider = "openai-main" # the default: the name of a section below openai-main { # key from OPENAI_API_KEY provider = "openai" model = "gpt-4o-mini" } claude { # key from ANTHROPIC_API_KEY provider = "anthropic" model = "claude-sonnet-4-20250514" } } } ``` ```scala import org.llm4s.config.Llm4sConfig val default = Llm4sConfig.defaultProvider() // openai-main val claude = Llm4sConfig.provider("claude") // any section, by name ``` Only the section being loaded is validated, so a section with no key available - or whose provider module is not on the classpath - fails only when it is loaded: with the file above and just `OPENAI_API_KEY` set, `defaultProvider()` succeeds and `provider("claude")` fails with an error naming `ANTHROPIC_API_KEY` and `claude`'s `apiKey`. See [Named provider sections](../getting-started/configuration#named-provider-sections) and [Switching providers](../getting-started/configuration#switching-providers) for the full story. ### Available Models See [MODEL_METADATA.md](/MODEL_METADATA.md) for the complete model list. Quick reference: **OpenAI:** `gpt-4o`, `gpt-4-turbo`, `gpt-3.5-turbo` **Anthropic:** `claude-opus-4-6`, `claude-sonnet-4-5-latest`, `claude-haiku-3-5` **Google Gemini:** `gemini-2.0-flash`, `gemini-1.5-pro`, `gemini-1.5-flash` **DeepSeek:** `deepseek-chat`, `deepseek-reasoner` **Mistral:** `mistral-large-latest`, `mistral-small-latest`, `magistral-medium-latest` **Cohere:** `command-a-03-2025` **Ollama:** `mistral`, `llama2`, `neural-chat`, `nomic-embed-text` (100+ models) --- ## OpenAI From the release after `0.4.1`, OpenAI support - chat, embeddings, and the Azure OpenAI and Requesty providers that share its client - ships in its own artifact, alongside `llm4s-core`; adding it registers the providers: ```scala libraryDependencies += "org.llm4s" %% "llm4s-openai" % llm4sVersion ``` In `0.4.1` and earlier it is part of `llm4s-core`. See the [installation guide](../getting-started/installation#for-openai-azure-openai-and-requesty). ### Setup 1. **Get an API key** from [platform.openai.com/api-keys](https://platform.openai.com/api-keys) 2. **Add a named section** to `application.conf` and select it as the default (below). 3. **Export the key** - `llm4s-openai` binds it to `llm4s.credentials.openai.apiKey`, which the section uses: ```bash export OPENAI_API_KEY=sk-proj-... ``` ### Configuration In `application.conf`: ```hocon llm4s { providers { provider = "openai-main" openai-main { provider = "openai" model = "gpt-4o" # apiKey = ${?OPENAI_BATCH_API_KEY} # optional; default: OPENAI_API_KEY organization = ${?OPENAI_ORGANIZATION} # optional, for multi-workspace accounts # baseUrl = "https://my-proxy.example.com/v1" # optional; default https://api.openai.com/v1 } } } ``` Without an `apiKey` line the section uses `OPENAI_API_KEY`. Set `apiKey` for a second account or a variable name of your own; it wins over the shared key. `OPENAI_ORGANIZATION` is read only because the section binds it. ### Available Models - **Latest:** `gpt-4o`, `gpt-4o-mini` - **Reasoning:** the gpt-5 family (`gpt-5`, `gpt-5-mini`, `gpt-5.1`, ...) and the o-series (`o3`, `o4-mini`) - **Turbo:** `gpt-4-turbo` - **Legacy:** `gpt-3.5-turbo` ### Reasoning models `CompletionOptions.reasoning` is sent as `reasoning_effort` to OpenAI's reasoning models: ```scala import org.llm4s.llmconnect.model.{ CompletionOptions, ReasoningEffort } // client: LLMClient, conversation: Conversation client.complete(conversation, CompletionOptions().withReasoning(ReasoningEffort.High)) ``` | `ReasoningEffort` | `reasoning_effort` | |---|---| | `Low` / `Medium` / `High` | `low` / `medium` / `high` | | `None` | not sent: the model's own default applies | Which models are reasoning models comes from the model registry's `supports_reasoning` flag (the same metadata llm4s uses for context windows and costs), so the o-series and the gpt-5 family get it, and `gpt-4o`, `gpt-4.1` and older models never do: OpenAI rejects the parameter for them, so for those models `reasoning` is ignored, as `CompletionOptions` documents. A model newer than the bundled metadata is recognised by its name (`o...`, `gpt-5` onwards, `gpt-oss`). For Requesty, a routed id such as `openai/o4-mini` resolves the same way; a non-OpenAI model behind Requesty is not sent `reasoning_effort`. A fine-tuned model is judged by the model it was trained from: `ft:o4-mini-2025-04-16:my-org:my-suffix:abc123` is treated as `o4-mini`, and a fine-tune of `gpt-4o-mini` as `gpt-4o-mini`. Requests to a reasoning model also follow OpenAI's rules for them: `maxTokens` is sent as `max_completion_tokens` (which counts reasoning tokens too) rather than `max_tokens`, and `temperature`, `topP`, `presencePenalty` and `frequencyPenalty` are not sent, since those models reject non-default values. (`gpt-oss` models keep their sampling parameters.) The reasoning tokens a response reports (`completion_tokens_details.reasoning_tokens`) are returned as `TokenUsage.thinkingTokens`, for streamed and non-streamed calls. They are part of `completionTokens`, not in addition to it. OpenAI's chat-completions API does not return the reasoning text itself, so `Completion.thinking` stays empty. ### Streaming and token usage Streaming requests set `stream_options.include_usage`, so OpenAI reports token usage on a final chunk with no choices, and a streamed `Completion` carries `usage` and `estimatedCost` like a non-streamed one. The final chunk produces no `StreamedChunk`. ### Costs See [OpenAI Pricing](https://openai.com/pricing). Generally: - `gpt-4o`: $2.50-$10 per 1M input tokens - `gpt-3.5-turbo`: $0.50-$1.50 per 1M input tokens ### Tips - Use `gpt-4o-mini` for cost-effective applications - Use `o1` for complex reasoning and math - Batching API available for high-volume use - Vision support in `gpt-4o` --- ## Anthropic From the release after `0.4.1`, Anthropic support ships in its own artifact, alongside `llm4s-core`; adding it registers the provider: ```scala libraryDependencies += "org.llm4s" %% "llm4s-anthropic" % llm4sVersion ``` In `0.4.1` and earlier it is part of `llm4s-core`. See the [installation guide](../getting-started/installation#for-anthropic). ### Setup 1. **Get an API key** from [console.anthropic.com](https://console.anthropic.com/account/keys) 2. **Add a named section** to `application.conf` and select it as the default (below). 3. **Export the key** - `llm4s-anthropic` binds it to `llm4s.credentials.anthropic.apiKey`: ```bash export ANTHROPIC_API_KEY=sk-ant-... ``` ### Configuration In `application.conf`: ```hocon llm4s { providers { provider = "anthropic-main" anthropic-main { provider = "anthropic" model = "claude-opus-4-6" # apiKey = ${?ANTHROPIC_TEAM_API_KEY} # optional; default: ANTHROPIC_API_KEY # baseUrl = "https://my-proxy.example.com" # optional; default https://api.anthropic.com } } } ``` ### Available Models - **Best Quality:** `claude-opus-4-6` (200K context) - **Balanced:** `claude-sonnet-4-5-latest` (200K context) - **Fast:** `claude-haiku-3-5` (200K context) ### Costs - `claude-opus-4-6`: $3-$15 per 1M input tokens - `claude-sonnet`: $3-$15 per 1M input tokens - `claude-haiku`: $0.80-$4 per 1M input tokens Claude models generally score higher on reasoning benchmarks. ### Tips - All Claude models have 200K context window - Exceptional at writing and analysis tasks - Excellent vision capabilities - Supports prompt caching for repeated queries ### Web search Set `webSearch = true` in an `anthropic` section and every request carries Anthropic's server-side web search tool (`web_search_20250305`). The model then decides per request whether to search, and the sources its answer cites arrive on `Completion.citations` (see [Citations and grounding sources](#citations-and-grounding-sources)): ```hocon llm4s.providers.claude-search { provider = "anthropic" model = "claude-sonnet-4-5" webSearch = true } ``` It is off by default. Anthropic bills each search on top of tokens, and an organization administrator has to allow web search in the Console first. The tool is sent beside your own tools; Anthropic runs the search itself, so it never reaches a `ToolRegistry` and the completion's `toolCalls` do not include it. --- ## Google Gemini From the release after `0.4.1`, Gemini (and Vertex AI) support ships in its own artifact, alongside `llm4s-core`; adding it registers both providers: ```scala libraryDependencies += "org.llm4s" %% "llm4s-gemini" % llm4sVersion ``` In `0.4.1` and earlier they are part of `llm4s-core`. See the [installation guide](../getting-started/installation#for-gemini-and-vertex-ai). ### Setup 1. **Get an API key** from [aistudio.google.com/apikey](https://aistudio.google.com/apikey) - Free tier available (60 requests per minute) 2. **Add a named section** to `application.conf` and select it as the default (below). 3. **Export the key** - `llm4s-gemini` binds `GOOGLE_API_KEY` and `GEMINI_API_KEY` to `llm4s.credentials.gemini.apiKey`, preferring `GOOGLE_API_KEY` when both are set, as Google's SDKs do. A `provider = "google"` section uses the same key. ```bash export GEMINI_API_KEY=your-api-key ``` ### Configuration In `application.conf`: ```hocon llm4s { providers { provider = "gemini-main" gemini-main { provider = "gemini" # "google" is accepted as an alias model = "gemini-2.0-flash" # apiKey = ${?GEMINI_TEAM_API_KEY} # optional; default: GOOGLE_API_KEY or GEMINI_API_KEY # baseUrl = "..." # optional; default https://generativelanguage.googleapis.com/v1beta } } } ``` ### Available Models - **Latest:** `gemini-2.0-flash` (1M context) - **Advanced:** `gemini-1.5-pro` (1M context) - **Fast:** `gemini-1.5-flash` (1M context) ### Costs - **Free tier:** 60 requests/minute, 2M free tokens/month - **Paid:** Pay as you go (~$0.075-$1.50 per 1M input tokens) Great for cost-conscious projects and high-volume applications. ### Tips - Free tier perfect for development and testing - 1M context window for processing large documents - Very fast inference latency - Strong code generation capabilities --- ### Google Search grounding Set `googleSearch = true` in a `gemini` or `vertexai` section and every request carries Gemini's Google Search tool (`google_search`; `googleSearch` on Vertex AI). The model then decides per request whether to search, and the sources behind a grounded answer arrive on `Completion.citations` (see [Citations and grounding sources](#citations-and-grounding-sources)): ```hocon llm4s.providers.gemini-search { provider = "gemini" model = "gemini-2.5-flash" googleSearch = true } ``` It is off by default. Google bills grounded requests separately, and the citation URLs it returns are `vertexaisearch.cloud.google.com` redirects, not the pages themselves. The tool is sent beside any function declarations; a model that does not support search together with function calling rejects that request. ## Google Vertex AI Vertex AI serves the same Gemini models from a GCP project, authenticated with OAuth2 rather than an API key. It ships in `llm4s-gemini` beside the Gemini API provider, so the dependency above registers both. ### Setup 1. **Pick a GCP project** with the Vertex AI API enabled, and a region that serves your model. 2. **Authenticate** with Application Default Credentials (`gcloud auth application-default login`), or point `apiKey` at a service-account JSON file. Without `apiKey`, credentials come from `GOOGLE_APPLICATION_CREDENTIALS`, then gcloud's `application_default_credentials.json`, then the GCE metadata server. The file is located as Google's client libraries do: in `$CLOUDSDK_CONFIG` when that is set (any OS), else `%APPDATA%\gcloud` on Windows, else `~/.config/gcloud`. ### Configuration ```hocon llm4s { providers { provider = "vertex-main" vertex-main { provider = "vertexai" # "vertex" is accepted as an alias model = "gemini-2.0-flash" project = ${?VERTEXAI_PROJECT} location = "europe-west4" # optional apiKey = ${?GOOGLE_APPLICATION_CREDENTIALS} # optional: path to a credential file } } } ``` | Key | Required | Meaning | |-----|----------|---------| | `project` | yes | The GCP project ID that owns your Vertex AI resources | | `location` | no | The GCP region; requests go to `https://-aiplatform.googleapis.com/v1`. Default `us-central1` | | `apiKey` | no | A path to a service-account credential file - not an API key | Because Vertex AI has no API key, it has no shared `llm4s.credentials` key: nothing is bound for it, and `GOOGLE_API_KEY` / `GEMINI_API_KEY` apply to the Gemini API provider only. `project` and `location` are Vertex AI's own keys ([provider-specific keys](#provider-specific-keys)). Before [#1215](https://github.com/llm4s/llm4s/issues/1215) Vertex read the project from `endpoint` and the region from `organization`. Those still work as deprecated aliases, with a warning each time the config loads; rename them. Setting both a key and a different value under its old name is an error. See the [migration note](../reference/migration#vertex-ai-endpoint-becomes-project-organization-becomes-location). --- ## Provider-specific keys Every named section shares the built-in fields - `provider`, `model`, `baseUrl`, `apiKey` and `headers` - and a provider may declare keys of its own. Its section of this guide lists them: | Provider | Its own keys | |---|---| | OpenAI, Requesty, OpenRouter | `organization` | | Azure OpenAI | `endpoint` (required), `apiVersion`, `authentication`, `clientId` | | Vertex AI | `project` (required), `location` | | Generic `openai-compatible` | `contextWindow`, `reserveCompletion`, `registryProvider`, `streamUsage` | A required one that is missing fails the config with its name, the section and what it means; a key that is neither built-in nor declared by the section's provider is ignored with a warning naming it, so a misspelt key - or `organization` in an Anthropic section - shows up in the log rather than silently doing nothing. `organization`, `endpoint`, `apiVersion`, `contextWindow` and `reserveCompletion` were built-in fields that every section carried until [#1133](https://github.com/llm4s/llm4s/issues/1133); the HOCON for the providers that use them is unchanged. --- ## IBM watsonx.ai > **Beta, never run against the live service.** `llm4s-watsonx` calls the watsonx.ai chat API > (`/ml/v1/text/chat` and `/ml/v1/text/chat_stream`), which replaces the "Infer text" endpoints IBM > [deprecated in February 2026](https://www.ibm.com/docs/en/software-hub/5.3.x?topic=new-watsonxai). > There is no watsonx account to test with, so nothing here has been checked against the real service. The > request and response shapes are the ones IBM's public pages report, cross-checked against IBM's own > open-source clients (the Node.js SDK and IBM's LangChain integration; the pinned sources are listed in > `WatsonXClient`'s Scaladoc): the token-limit field, the encoding of tool-call arguments, the finish reasons > and the stream's usage chunk are all shown there. IBM's > [tool-calling guide](https://www.ibm.com/docs/SSYOK8/wsj/analyze-data/fm-api-chat-tools.html) shows the > same tool request (`tool_choice_option: "auto"`) and tool-call reply, and a test pins its example. That proves IBM's clients send them, not that the > service accepts them today. The module is Beta and its API is not frozen. The `@Cloud` suite > `WatsonXAssumptionProbeSpec` (modules/it) checks each of those details against a real account and prints > which held; running it is still open in [#1314](https://github.com/llm4s/llm4s/issues/1314). Until it has > been run, treat tool calling and streaming as unconfirmed. IBM's enterprise AI platform, serving Granite, Llama and Mistral models. It lives in its own module, `llm4s-watsonx`; adding the dependency registers the `watsonx` provider. ```scala libraryDependencies += "org.llm4s" %% "llm4s-watsonx" % llm4sVersion ``` ```hocon llm4s.providers.watsonx-main { provider = "watsonx" model = "ibm/granite-4-h-small" # any chat model your watsonx.ai region serves projectId = ${?WATSONX_PROJECT_ID} # or spaceId for a deployment space # baseUrl = "https://eu-de.ml.cloud.ibm.com" # default https://us-south.ml.cloud.ibm.com # apiVersion = "2026-09-25" # the `version` query parameter; default as shown } ``` `WATSONX_API_KEY` supplies the IBM Cloud API key, which the client exchanges for an IAM bearer token and refreshes before it expires. The conversation goes as structured `messages` with roles (`system`, `user`, `assistant`, `tool`), so user content is data and cannot forge a turn. Behaviour to know about: - **Tools.** `CompletionOptions.tools` are sent as `tools` with `tool_choice_option: "auto"`, a reply's `tool_calls` (whole, or streamed as `delta` pieces) become `ToolCall`s, and a tool result goes back as a `tool` message with its `tool_call_id`. Streamed calls are reported once, whole, after the last delta. Whether a model supports tools depends on the model; a model that does not is answered by the service, and that error is returned as the HTTP status maps it. A call the service sends without an `id` is given a generated one. - **Sampling options.** `temperature` is always sent, `maxTokens` (as `max_tokens`) and `topP` (when not 1.0) when set. `presencePenalty` and `frequencyPenalty` go as `presence_penalty` and `frequency_penalty` when not 0.0, their default, so leaving them alone sends nothing new; the service checks their range. - **Structured output.** `responseFormat` goes as `response_format`: `ResponseFormat.Json` as `{"type": "json_object"}`, and `ResponseFormat.JsonSchema(schema, name, strict)` as `{"type": "json_schema", "json_schema": {"name": ..., "schema": ..., "strict": ...}}`, the shapes IBM's Python SDK and LangChain integration send (IBM's Node.js SDK names only `json_object`). JSON mode needs the prompt to ask for JSON too; the client adds no instruction. Whether a model honours either format depends on the model. Like the rest of this section, both are unconfirmed until the probe suite runs. - **Reasoning.** `CompletionOptions.reasoning` goes as `reasoning_effort` (`Low`, `Medium` and `High` as `low`, `medium` and `high`) to OpenAI's gpt-oss models (`openai/gpt-oss-120b`, `openai/gpt-oss-20b` and their variants), the models IBM's own clients send it to. The chat API has no value that turns reasoning off, so `ReasoningEffort.None` goes as `low`, and a warning is logged once per process. Any other model is sent nothing for `reasoning`, again with a warning logged once per process: IBM's Model Gateway rejects the field for models without it, and whether the chat API does is unconfirmed. `include_reasoning` is not sent; the service defaults it to true. - **Thinking.** A reply's `reasoning_content` (or `reasoning`) is read as thinking from any model: `Completion.thinking` on `complete`, and `StreamedChunk.thinkingDelta` on a stream, with the whole text on the final completion. It is not sent back on later turns. The chat API reports no reasoning token count, so `TokenUsage.thinkingTokens` stays empty (IBM's recorded replies suggest the reasoning is counted in `completion_tokens`). `budgetTokens` has no counterpart and is ignored. Granite models that put their reasoning inside the content between tags (`...`) are not split: that text stays in `content`. The field names come from IBM's SDKs and from replies IBM's Java SDK recorded from `openai/gpt-oss-120b`; like the rest of this section, they are unconfirmed until the probe suite runs. - **Abnormal endings.** A stream that ends without a `finish_reason`, or whose `finish_reason` is `error`, `cancelled` or `time_limit`, returns `Left(ServiceError)` naming the reason, not the partial text (chunks already passed to `onChunk` were delivered). `stop`, `length`, `tool_calls` and unknown reasons are normal stops. `complete` applies the same rule to `choices[0].finish_reason`. - **Stream error events.** An `event: error` frame ends the stream with the error IBM sends in its `data:` line: its `status_code` picks the error as an HTTP status would (`AuthenticationError` for 401 and 403, `RateLimitError` for 429, `ValidationError` for 400, `ServiceError` otherwise, and `ServiceError(502)` without one), and the message is IBM's error `code` and `message`, with the API key and bearer token removed. - **API version.** Every request carries `version=`. The default, `2026-09-25`, is the date IBM's Python SDK (`ibm-watsonx-ai` 1.8.0) sends; set `apiVersion` in the section to pin another. - **URLs and ids.** `baseUrl` and `iamUrl` must be `https` (plain `http` only for `localhost`, `127.0.0.1` and `::1`), because the IAM request carries the API key. The API key is trimmed. Set `projectId` or `spaceId`, not both. - **Environment variables.** Only `WATSONX_API_KEY` is bound automatically. `WATSONX_PROJECT_ID` and the other variables in the example are just `${?VAR}` substitutions you write in your own config. --- ## Azure OpenAI Azure OpenAI ships in `llm4s-openai` with OpenAI itself, from the release after `0.4.1`; see [OpenAI](#openai) above. It runs on OpenAI's Java SDK (`com.openai:openai-java`), which covers Azure too: requests go to `/openai/deployments//chat/completions` with an `api-key` header and the `api-version` query parameter. `apiVersion` accepts the wire form (`2024-10-21`) or the constant name (`V2024_10_21`); an endpoint ending in `/openai/v1` uses Azure's unified v1 API. ### Setup 1. **Create resource** in [Azure Portal](https://portal.azure.com) 2. **Deploy model** (e.g., gpt-4o) to get deployment name 3. **Get credentials** from Azure Portal → Keys & Endpoint 4. **Add a named section** to `application.conf` (below) and export the key, which `llm4s-openai` binds to `llm4s.credentials.azure.apiKey` under the openai SDK's name, and the endpoint the section binds: ```bash export AZURE_OPENAI_API_KEY=your-azure-key export AZURE_API_BASE=https://your-resource.openai.azure.com ``` ### Configuration In `application.conf`: ```hocon llm4s { providers { provider = "azure-main" azure-main { provider = "azure" model = "gpt-4o" # your deployment name endpoint = ${?AZURE_API_BASE} # required: your resource's endpoint # apiKey = ${?AZURE_TEAM_API_KEY} # optional; default: AZURE_OPENAI_API_KEY apiVersion = "2024-10-21" # optional; default V2025_01_01_PREVIEW } } } ``` | Key | Required | Meaning | |-----|----------|---------| | `model` | yes | The deployment name you chose when deploying the model | | `endpoint` | yes | The Azure OpenAI resource endpoint | | `apiKey` | no | The resource key; default `AZURE_OPENAI_API_KEY` | | `apiVersion` | no | Azure API version, wire form or constant name | | `authentication` | no | `api-key` (default) or `entra-id`: keyless [Microsoft Entra ID](#azure-openai-with-microsoft-entra-id) | | `clientId` | no | With `entra-id`, the client id of a user-assigned managed identity | ### Available Models Same as OpenAI (via Azure deployment). Choose models when deploying: - `gpt-4o` - `gpt-4-turbo` - `gpt-35-turbo` ### Reasoning deployments `reasoning_effort` works as for [OpenAI](#reasoning-models), with one difference: the model llm4s sees is the **deployment name**, which need not name the model behind it. So: - a deployment named after a model the registry knows (`o4-mini`, `gpt-5-mini`, `gpt-4o`) is treated as that model, and one named after an Azure fine-tuned model (`o4-mini-2025-04-16.ft-...`) as its base model; - any other deployment (`prod-reasoner`, or a fine-tune deployed under a name of your own) gets `reasoning_effort` whenever you ask for a reasoning effort, since asking is the only sign it is a reasoning model. The request then also follows the reasoning-model rules: `max_completion_tokens`, and no sampling parameters. Asking for reasoning on such a deployment that is not a reasoning model gets an error from Azure; leave `reasoning` unset (or `ReasoningEffort.None`) for it. ### Streamed token usage Streamed completions report token usage when `apiVersion` is `2024-09-01-preview` or later (including the default `2025-01-01-preview`, the GA `2024-10-21` and the unified v1 API): only those versions accept `stream_options`, so it is not sent for older ones, such as `2024-02-15-preview`, and their streams report no usage. ### Costs Similar to OpenAI but often bundled with enterprise agreements. ### Tips - Use for VPC-isolated workloads - Enterprise support available - Same API as OpenAI (easy migration) - Reserve capacity for predictable costs --- ## DeepSeek From the release after `0.4.1`, DeepSeek ships in `llm4s-openai-compatible`, with Z.ai, OpenRouter and the generic [OpenAI-compatible](#openai-compatible-endpoints) provider; adding it registers them: ```scala libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion ``` In `0.4.1` and earlier it is part of `llm4s-core`. See the [installation guide](../getting-started/installation#for-deepseek-zai-openrouter-mistral-cohere-and-any-openai-compatible-endpoint). ### Setup 1. **Get API key** from [platform.deepseek.com](https://platform.deepseek.com/api_keys) 2. **Export the key** - `llm4s-openai-compatible` binds it to `llm4s.credentials.deepseek.apiKey`: ```bash export DEEPSEEK_API_KEY=sk-... ``` ### Configuration In `application.conf`: ```hocon llm4s { providers { provider = "deepseek-main" deepseek-main { provider = "deepseek" model = "deepseek-chat" # apiKey defaults to DEEPSEEK_API_KEY # baseUrl defaults to https://api.deepseek.com } } } ``` ### Available Models - **Chat:** `deepseek-chat` (best for general use) - **Reasoning:** `deepseek-reasoner` (extended thinking). Its chain of thought (`reasoning_content`) is returned on the message as `AssistantMessage.thinking` (and so `Completion.thinking`), streamed as thinking deltas, and sent back as `reasoning_content` in later requests, which DeepSeek's thinking mode requires once tools are involved (see [Thinking in conversation history](#thinking-in-conversation-history)). ### Costs Very competitive: ~$0.14-$0.28 per 1M input tokens ### Tips - Excellent cost/performance ratio - Reasoning model rivals GPT-4o - Good for translations and multilingual tasks - Supports very long contexts --- ## OpenRouter and Z.ai Both ship in `llm4s-openai-compatible` from the release after `0.4.1`, as DeepSeek does. ```hocon llm4s { providers { openrouter-main { provider = "openrouter" model = "anthropic/claude-3.5-sonnet" # apiKey defaults to OPENROUTER_API_KEY # baseUrl defaults to https://openrouter.ai/api/v1 } zai-main { provider = "zai" model = "GLM-4.7" # apiKey defaults to ZAI_API_KEY # baseUrl defaults to https://api.z.ai/api/paas/v4 } } } ``` Both sections are shown together for brevity. Only the section you load is validated, so only its key - `OPENROUTER_API_KEY` or `ZAI_API_KEY` - needs to be set. OpenRouter maps `CompletionOptions.reasoning` onto the underlying model: a thinking budget for Claude models, `reasoning_effort` for OpenAI o-series models, nothing for the rest. Z.ai maps it onto what the configured GLM model documents. Thinking is on by default, and `reasoning_effort` defaults to `max` on the models that accept it. The mapping only ever rises with the effort, and `High` sends `max`, Z.ai's maximum and its default, so no level reasons more than `High`: | Model | `ReasoningEffort.None` | `Low` | `Medium` | `High` | |---|---|---|---|---| | GLM-5.3, GLM-5.3-Flash, GLM-5.3-FlashX | `reasoning_effort: low` | `low` | `high` | `max` | | GLM-5.2 | `reasoning_effort: none` | `low` | `medium` | `max` | | GLM-5.1, GLM-5, GLM-4.7, GLM-4.6, GLM-4.5 (and variants) | `thinking.type: disabled` | not sent | not sent | not sent | | any other model | not sent | not sent | not sent | not sent | GLM-5.3 always thinks and rejects `thinking.type: disabled`, so `None` gets the lowest effort it accepts, as Z.ai advises; it still produces, and bills, thinking tokens, and the first such request in a process logs a warning naming the model. GLM-5.3 accepts only `low`, `high` and `max`. GLM-5.2 accepts `none`, `low`, `medium`, `high` and `max`, but Z.ai currently runs `low` and `medium` as `high` on it; llm4s still sends the level requested. The GLM-4.x and earlier GLM-5 models document no `reasoning_effort`. With `reasoning` unset, neither field is sent. A GLM thinking model's `reasoning_content` on Z.ai, and a model's `reasoning` and `reasoning_details` on OpenRouter, are returned on the message and sent back (see [Thinking in conversation history](#thinking-in-conversation-history)). --- ## OpenAI-compatible endpoints Any server that speaks the OpenAI `/chat/completions` API works with `provider = "openai-compatible"` - hosted APIs such as Groq, Together, Fireworks, xAI or NVIDIA NIM, local servers such as vLLM, LM Studio or llama.cpp, or an internal gateway. It needs no code and no module of its own, only `llm4s-openai-compatible`, which ships in the release after `0.4.1` (see the [installation guide](../getting-started/installation#for-deepseek-zai-openrouter-mistral-cohere-and-any-openai-compatible-endpoint)): ```scala libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion ``` Each named section is one endpoint, so several can sit side by side. The [recipes](#hosted-apis) below are sections you can paste into your `application.conf` under `llm4s.providers`. ### Configuration keys | Key | Required | Meaning | |-----|----------|---------| | `baseUrl` | yes | Requests go to `/chat/completions`, and model listing to `/models`. A trailing `/` is dropped | | `model` | yes | Sent as-is in every request | | `apiKey` | no | Sent as `Authorization: Bearer `; with none, no `Authorization` header is sent | | `contextWindow` | no | The model's context window; overrides the model registry. With none, the registry's window for `model` when it has one of at least 8192 (see [where the context window comes from](#where-the-context-window-comes-from)), else 8192, which is deliberately small | | `reserveCompletion` | no | Tokens held back for the reply. Default 2048, or a quarter of a smaller window | | `registryProvider` | no | The [model registry](../MODEL_METADATA.md) provider whose entry for `model` gives the context window when `contextWindow` is not set: `groq`, `together_ai`, `fireworks_ai`, `xai`, `perplexity`, ... Inferred from the `baseUrl` host for those five hosted APIs; naming one here switches the inference off | | `headers` | no | Extra headers sent on every request; values are redacted when the config is printed | | `streamUsage` | no | Whether a streaming request asks for token usage with `stream_options.include_usage`. Default `true`; set `false` for an endpoint that rejects the field (see [streamed token usage](#what-the-generic-path-does-and-does-not-do)) | `baseUrl` is everything before `/chat/completions` in the vendor's endpoint URL. For most vendors that is the host plus `/v1`, but some add a prefix - Groq's is `/openai/v1`, Fireworks' `/inference/v1` - and Perplexity's Sonar API has no `/v1` at all. ### Where the context window comes from The generic provider cannot ask an arbitrary endpoint how large the model's window is, so it takes it from the first of these that has an answer: 1. the section's `contextWindow`; 2. the [model registry](../MODEL_METADATA.md)'s entry for `model` under the `registryProvider` the section names; 3. when the section names none, the registry's entry under the provider inferred from the `baseUrl` host, for exactly these hosts: `api.groq.com` (`groq`), `api.together.xyz` and `api.together.ai` (`together_ai`), `api.fireworks.ai` (`fireworks_ai`), `api.x.ai` (`xai`) and `api.perplexity.ai` (`perplexity`); 4. 8192. ```hocon internal-gateway { provider = "openai-compatible" baseUrl = "https://llm-gateway.internal.example/v1" # not a known host, so name the registry provider model = "llama-3.1-8b-instant" registryProvider = "groq" # the window comes from groq/llama-3.1-8b-instant } ``` - **An explicit `registryProvider` is an instruction, not a hint.** It switches the host inference off, so a `registryProvider = "perplexity"` on a Groq URL looks in Perplexity's entries only. - **The lookup is strict.** Only the named provider's own entry for exactly that model id counts. A partial name does not match, and neither does another provider's entry for the same name, so a model the registry does not list under that provider gets the default, never a neighbour's window. - **The host is matched exactly, never by substring.** `https://api.groq.com.evil.example/v1`, `https://api.groq.com@evil.example/v1` and `https://evil.example/api.groq.com` all get no registry window. NVIDIA NIM is not in the table because the registry's three `nvidia_nim` entries carry no chat model with a window; `registryProvider = "nvidia_nim"` is still accepted. - **The registry can only enlarge the window.** A registry window below 8192 is ignored and the default used: many entries, most of Fireworks' and Perplexity's older ones among them, carry a 4096 placeholder for input, output and total alike (`fireworks_ai/.../minimax-m1-80k` is one), which would cut an 80k-context model's prompt budget in half and reject a `reserveCompletion` that fits 8192. A model whose window really is smaller sets `contextWindow`. The skipped entry is logged like a miss. - **Only the window is taken.** `reserveCompletion` keeps its own rule, a quarter of the window up to 2048, because a registry entry's output limit can be as large as the whole window. - **The registry does not know every model.** It has no input limit for the Together recipe's model, and does not list xAI's `grok-4.7` or the NVIDIA NIM model at all, so those recipes keep their `contextWindow`. A miss under an explicit `registryProvider` is logged as a warning, and under an inferred one at info; a hit is logged at info with the entry it used. ### Selecting it, and environment variables `Llm4sConfig` resolves providers from named sections only, and reads no `LLM_MODEL`. The generic provider has no vendor, so - unlike `openai` or `anthropic` - it has no shared `llm4s.credentials` key and no variable is bound for it. Every value lives in the named section, and you bind environment variables to it with HOCON substitutions, under names you choose. The recipes use each vendor's own documented variable name, such as `GROQ_API_KEY`: ```hocon llm4s { providers { # the default named provider; LLM4S_PROVIDER overrides it when set provider = "groq-main" provider = ${?LLM4S_PROVIDER} groq-main { provider = "openai-compatible" baseUrl = "https://api.groq.com/openai/v1" model = "openai/gpt-oss-120b" model = ${?GROQ_MODEL} # optional override apiKey = ${?GROQ_API_KEY} } } } ``` `${?VAR}` leaves the key unset when the variable is missing. For `apiKey` that is not an error when the config loads, because the key is optional: the request goes out without an `Authorization` header and fails with a 401 (see [Troubleshooting](#troubleshooting-openai-compatible-endpoints)). For an endpoint chosen entirely from the environment, the conventional names are `OPENAI_COMPATIBLE_BASE_URL` (required: the generic provider has no default endpoint) and `OPENAI_COMPATIBLE_API_KEY` (optional), which `OpenAICompatibleConfigKeys` names: ```hocon llm4s.providers { provider = "compatible-env" compatible-env { provider = "openai-compatible" baseUrl = ${?OPENAI_COMPATIBLE_BASE_URL} model = ${?OPENAI_COMPATIBLE_MODEL} apiKey = ${?OPENAI_COMPATIBLE_API_KEY} } } ``` With `OPENAI_COMPATIBLE_BASE_URL` or the model variable unset, that section fails to load, so keep it in the `application.conf` of deployments that set them. The `LLM_MODEL=openai-compatible/` shorthand is read, as `LLM_MODEL=deepseek/` and the other providers' are, by the tools that read `LLM_MODEL` themselves: the chat-tui sample, which then takes the endpoint from `OPENAI_COMPATIBLE_BASE_URL` and the key from `OPENAI_COMPATIBLE_API_KEY`, and the config-policy env check, which checks `OPENAI_COMPATIBLE_BASE_URL` rather than `OPENAI_BASE_URL` as the endpoint. Only the first `/` separates the provider, so `LLM_MODEL=openai-compatible/openai/gpt-oss-120b` names Groq's `openai/gpt-oss-120b`. Then load it by name, or as the default: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.config.ProviderConfig import org.llm4s.types.Result val groq: Result[ProviderConfig] = Llm4sConfig.provider("groq-main") val dflt: Result[ProviderConfig] = Llm4sConfig.defaultProvider() ``` ### What the generic path does, and does not do It sends the plain chat-completions format and reads the plain reply: text, **streaming**, **tool calling** (streamed and not), `response_format` for JSON output, and token usage. Every request carries `temperature` and `top_p`; `max_tokens` is sent when set, and `presence_penalty` and `frequency_penalty` only when non-zero. It never sends `stop` or `tool_choice`. Whether a feature works also depends on the endpoint and the model: tool calling on a local server usually needs a server flag, as the recipes note. **Streamed token usage.** A streaming request asks for usage with `"stream_options": {"include_usage": true}`, which is how OpenAI - and servers that follow it, such as vLLM, Ollama's `/v1` and Perplexity's Router - are told to report it; usage is read from whichever event carries it, including a final event with empty `choices`. An endpoint that rejects unknown fields may refuse the request with a 400 or 422 naming `stream_options`. Turn it off with `streamUsage = false` in the section (it takes `true` or `false`, and HOCON's `yes`/`no`/`on`/`off`; anything else is a config error naming the key and section): ```hocon strict-gateway { provider = "openai-compatible" baseUrl = "https://llm.example/v1" model = "my-model" streamUsage = false } ``` or, building the config in code, with `OpenAICompatibleConfig.fromValues(..., streamUsage = false)`. A non-streaming request fails with a timeout after **two minutes** without a response, and a streaming one after five minutes without one. Both are fixed for now; configurable timeouts are [#712](https://github.com/llm4s/llm4s/issues/712). A slow local model answering a long prompt can take longer than two minutes: stream it instead. What it does **not** do: - **No reasoning configuration.** `CompletionOptions.reasoning` is ignored; no `reasoning_effort` or similar field is sent, so a reasoning model runs at its vendor's default. - **No reasoning output.** A reply's `reasoning_content` or `reasoning` field is not read, so `Completion.thinking` is empty and reasoning tokens are not reported separately. - **No provider-specific fields.** Anything outside the standard reply - citations, search results, annotations - is dropped; `Completion` has no field for it. - **Text only.** Messages are sent as text. - **No cost estimate** unless the model name is one llm4s's model registry knows. A provider that needs any of these gets its own **dialect in `llm4s-openai-compatible`**: an `OpenAICompatibleDialect` for how it departs from the standard format (reasoning parameters, where its thinking is, extra response decoding) and a `ProviderDescriptor` to register it, as DeepSeek, Z.ai, OpenRouter, Mistral and Cohere have. [Adding an OpenAI-compatible provider](https://github.com/llm4s/llm4s/blob/main/CONTRIBUTING.md#adding-an-openai-compatible-provider-a-dialect) in the contributing guide walks through the hooks, a worked example, registration, the tests to write and the docs to update; see also [Adding a provider](../reference/migration#adding-a-provider) for the descriptor. ### Hosted APIs The values below were checked against each vendor's API documentation in September 2026 (links under [Provider docs](#provider-docs)). Model catalogues change often: if a model is retired, pick a current one from the vendor's list and keep the rest of the section. #### Groq ```hocon groq-main { provider = "openai-compatible" baseUrl = "https://api.groq.com/openai/v1" model = "openai/gpt-oss-120b" apiKey = ${?GROQ_API_KEY} reserveCompletion = 8192 } ``` - The context window, 131072 for this model, comes from the model registry, because `api.groq.com` is a known host. Set `contextWindow` to override it, or for a model the registry does not list. - Model ids are Groq's own, including the `openai/` prefix here. `llama-3.3-70b-versatile`, which earlier versions of this guide used, was shut down for free and developer tiers on 16 August 2026. - `gpt-oss-120b` is a reasoning model. Groq returns its reasoning in a `reasoning` field, which is not read, and the effort cannot be set. - Groq does not support `logprobs`, `logit_bias`, `messages[].name` or `n` > 1; the generic path sends none of them. A `temperature` of 0 is converted to 1e-8. #### Together AI ```hocon together-main { provider = "openai-compatible" baseUrl = "https://api.together.ai/v1" model = "meta-llama/Llama-3.3-70B-Instruct-Turbo" apiKey = ${?TOGETHER_API_KEY} contextWindow = 131072 } ``` - Keep `contextWindow`: the registry lists this model with no input limit, so without it the provider would use 8192. (For a Together model the registry does give a limit for, it is taken automatically.) - Model ids are the `/` API strings from Together's serverless model list. - For a reasoning model on Together, the same reasoning limits apply as for Groq above. #### Fireworks AI ```hocon fireworks-main { provider = "openai-compatible" baseUrl = "https://api.fireworks.ai/inference/v1" model = "accounts/fireworks/models/gpt-oss-120b" apiKey = ${?FIREWORKS_API_KEY} } ``` - The context window, 131072 for this model, comes from the model registry. Set `contextWindow` to override it, or for a model the registry does not list. - Model ids are full paths, `accounts/fireworks/models/`. Not every model in the catalogue is available serverless: check its model page before using it with a plain API key. - If the prompt plus `max_tokens` exceeds the model's window, Fireworks lowers `max_tokens` rather than failing (its default `context_length_exceeded_behavior` is `truncate`). - Fireworks sends usage on the last chunk of a stream, so streamed token usage is reported. `reasoning_content` is not read. #### xAI (Grok) xAI has a provider of its own, [`provider = "xai"`](#xai-grok), which reads Grok's reasoning and sends `CompletionOptions.reasoning`; prefer it. The generic path still works, for chat, streaming and tools only: ```hocon xai-generic { provider = "openai-compatible" baseUrl = "https://api.x.ai/v1" model = "grok-4.7" apiKey = ${?XAI_API_KEY} contextWindow = 500000 } ``` - Keep `contextWindow`: the model registry does not list `grok-4.7`, so without it the provider would use 8192. (For an xAI model the registry does list, such as `grok-2`, it is taken automatically.) - On this path Grok's reasoning output is not read and the effort cannot be set, and xAI rejects `presencePenalty` and `frequencyPenalty` on reasoning models: leave them at 0. #### NVIDIA NIM (hosted) ```hocon nim-cloud { provider = "openai-compatible" baseUrl = "https://integrate.api.nvidia.com/v1" model = "meta/llama-3.3-70b-instruct" apiKey = ${?NVIDIA_API_KEY} contextWindow = 128000 } ``` - Keep `contextWindow`: the model registry has no usable chat entry for NVIDIA NIM, and `integrate.api.nvidia.com` is not one of the hosts the registry provider is inferred from. - Model ids are `/`, as in NVIDIA's API catalogue. For a NIM you run yourself, see [NVIDIA NIM (self-hosted)](#nvidia-nim-self-hosted). #### Perplexity > Perplexity has replaced **Sonar Chat Completions** with its Agent API, and states that Sonar > "will be supported until September 27, 2026". The Agent API uses the OpenAI **Responses** > format (`/v1/responses`), which the generic provider does not speak. Check Sonar's status > before relying on the section below. Sonar, while it runs: ```hocon perplexity-sonar { provider = "openai-compatible" baseUrl = "https://api.perplexity.ai" # no /v1: requests go to /chat/completions model = "sonar-pro" apiKey = ${?PERPLEXITY_API_KEY} } ``` - The context window, 200000 for `sonar-pro`, comes from the model registry. - **Citations and search results are not surfaced.** Sonar returns them in top-level `citations` and `search_results` fields, and `Completion` has no field for them, so you get the answer text - with its `[1]`-style markers - and not the sources they point to. - Sonar's tool-calling support is undocumented; do not rely on it. Perplexity's **Router API** speaks Chat Completions and serves open-weight models, without Sonar's web search. It is in private preview (access by request to Perplexity), and publishes no context windows, so set `contextWindow` for the model you pick: ```hocon perplexity-router { provider = "openai-compatible" baseUrl = "https://api.perplexity.ai/router/v1" model = "perplexity/glm-5.3-flash" apiKey = ${?PERPLEXITY_API_KEY} } ``` The Router reports token usage on a stream only when asked with `stream_options`, which the generic provider sends, so streamed completions carry usage. #### OrcaRouter [OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible gateway in front of several vendors' models: ```hocon orcarouter-main { provider = "openai-compatible" baseUrl = "https://api.orcarouter.ai/v1" model = "openai/gpt-4o-mini" apiKey = ${?ORCAROUTER_API_KEY} # keys start with sk-orca- } ``` - OrcaRouter documents no environment variable name; `ORCAROUTER_API_KEY` is only this guide's choice. It publishes no per-model context window either: set `contextWindow` to the upstream model's. - Model ids are provider-prefixed (`openai/`, `anthropic/`, `google/`, ...). OrcaRouter accepts a reasoning effort as a model-name suffix (`-minimal`, `-low`, `-medium`, `-high`, `-max`), and since `model` is sent as-is, that is the one way to choose an effort through the generic path. The `reasoning_content` it passes through is not read. ### Local servers A local server usually takes no API key: leave `apiKey` out and no `Authorization` header is sent. Set it only if you started the server with one. Set `contextWindow` to the context the server was started with, which may be smaller than the model's maximum. #### vLLM ```bash vllm serve NousResearch/Meta-Llama-3-8B-Instruct --dtype auto ``` ```hocon local-vllm { provider = "openai-compatible" baseUrl = "http://localhost:8000/v1" model = "NousResearch/Meta-Llama-3-8B-Instruct" # --served-model-name, if you set one # apiKey = ${?VLLM_API_KEY} # only if started with --api-key } ``` - The served model name defaults to the `--model` argument. - Tool calling needs `--enable-auto-tool-choice` and a `--tool-call-parser` for the model's family; without them the model never calls a tool. #### LM Studio Start the server from LM Studio's Developer page (default port 1234), then: ```hocon lmstudio { provider = "openai-compatible" baseUrl = "http://localhost:1234/v1" model = "" # apiKey = ${?LM_API_TOKEN} # only with "Require Authentication" on (LM Studio 0.4.0+) } ``` - Use the identifier LM Studio shows for the loaded model, or list them with `Llm4sConfig.listModels("lmstudio")`. #### llama.cpp (`llama-server`) ```bash llama-server -hf /: --alias local-model -c 16384 ``` ```hocon llama-cpp { provider = "openai-compatible" baseUrl = "http://localhost:8080/v1" model = "local-model" # the --alias contextWindow = 16384 # the -c / --ctx-size the server was started with # apiKey = ${?LLAMA_API_KEY} # only if started with --api-key } ``` - `llama-server` listens on `127.0.0.1:8080` by default. Without `--alias` the model id is the model file's path; `--alias` gives it a stable name. - Tool calling uses the Jinja chat template (`--jinja`, on by default), and some models need a `--chat-template-file` override to get a tool-capable template. #### NVIDIA NIM (self-hosted) ```hocon nim-local { provider = "openai-compatible" baseUrl = "http://localhost:8000/v1" model = "meta/llama-3.1-8b-instruct" # the id GET /v1/models reports # no apiKey: NVIDIA's examples for a running NIM send none } ``` - `NGC_API_KEY` is for pulling the container and model, not for requests: do not put it in `apiKey`. - The model id is the `id` from `GET /v1/models`; for a model-free NIM it is `NIM_SERVED_MODEL_NAME`. #### Ollama (`/v1`) [`llm4s-ollama`](#ollama-local-models) (`provider = "ollama"`) is the first-class route to Ollama: it uses Ollama's native `/api/chat` API, supports **tool calling** (tools are sent, tool calls are read - whole or streamed - and tool results go back as `role: tool`) and also provides Ollama embeddings. Tool calling needs a model whose Ollama page lists the **tools** capability: for any other model Ollama answers HTTP 400 (`... does not support tools`), which the client reports as an `Invalid tools: Ollama model '' does not support tool calling ...` validation error rather than sending the request again without the tools. Use the generic provider on Ollama's OpenAI-compatible `/v1` endpoint instead when Ollama is behind a gateway that exposes only the OpenAI API, or when you prefer that wire format: ```hocon ollama-openai { provider = "openai-compatible" baseUrl = "http://localhost:11434/v1" model = "gpt-oss:20b" # no apiKey: local Ollama needs none } ``` - The OpenAI API cannot set Ollama's context size. To change it, create a model from a `Modelfile` with `PARAMETER num_ctx `, use that model's name, and set `contextWindow` to the same size. - Ollama's cloud API works the same way, with `baseUrl = "https://ollama.com/v1"` and `apiKey = ${?OLLAMA_API_KEY}`. - A reasoning model's thinking is not read on this route. ### An internal gateway A gateway that authenticates with its own header rather than a bearer token takes it in `headers`: ```hocon internal-gateway { provider = "openai-compatible" baseUrl = "https://llm-gateway.internal.example/v1" model = "gpt-4o-mini" headers { X-Team = "search" X-Gateway-Token = ${?GATEWAY_TOKEN} } } ``` ### Config policy If you gate configs with `llm4s-config-policy`, its `dev` preset allows `openai-compatible` and its `prod` preset does not: since the provider can point anywhere, a production config naming it fails with `[allowedProviders] Provider 'openai-compatible' is not allowed` until you allow it. Allow it explicitly, and pin the models and the endpoint: ```scala import org.llm4s.configpolicy.{ CatalogEnvironment, ConfigPolicy } val policy = ConfigPolicy.prodSafeDefaults .withAllowedProviders("openai", "anthropic", "azure", "gemini", "deepseek", "openai-compatible") .withAllowedModelPatterns( "^openai/gpt-4o(-mini)?$", // ...and the other preset patterns you still need "^openai-compatible/openai/gpt-oss-120b$" ) .withRequiredBaseUrlPattern( CatalogEnvironment.Prod, "openai-compatible", "https://api\\.groq\\.com/openai/v1" ) .withMaxContextWindow(CatalogEnvironment.Prod, "openai-compatible", 131072) ``` - `withAllowedProviders` and `withAllowedModelPatterns` **replace** the preset's lists; repeat the entries you want to keep. - The `prod` preset also requires every section whose provider needs a key to set its own `apiKey` (rule `ownApiKey`, `withOwnApiKeyRequired`). The generic provider's `apiKey` is optional, so it is not checked; the rule is about sections that would otherwise inherit a vendor's shared key. - Model patterns match `/`, so a Groq model is `openai-compatible/openai/gpt-oss-120b`. Model and base-URL patterns are regular expressions that must match the **whole** value, so `^` and `$` are optional: `openai/gpt-4o` allows neither `openai/gpt-4o-mini` nor a dated snapshot such as `openai/gpt-4o-2024-08-06` (write `openai/gpt-4o(-.*)?` for those). End a base-URL pin that allows paths with `/.*`, never a bare `.*`: `https://api\.groq\.com.*` also accepts `https://api.groq.com.evil.example/v1`. - `withRequiredBaseUrlPattern(env, provider, pattern)` pins one provider's endpoint and **replaces** the environment-wide pin for that provider. The environment-wide form, `withRequiredBaseUrlPattern(env, pattern)`, is checked against every provider without a pin of its own, not only `openai-compatible`; if you use it, name every endpoint in it (for example `https://(api\.openai\.com|api\.groq\.com/openai)/v1`). - Context caps work the same way. The `prod` preset caps each of its providers at its current models' window (openai and azure 128000, anthropic 200000, gemini 1048576, deepseek 131072) and any other provider, `openai-compatible` included, at an environment-wide 1048576; `dev` caps everything at 1048576. So Groq, Together or Fireworks at 131072 and xAI at 500000 pass as they are. Set a per-provider cap with `withMaxContextWindow(env, provider, max)`, as above, to hold a provider tighter, or to allow a model above 1048576. A section over its cap fails with `contextWindow ... exceeds `. A window taken from the model registry counts too: a Groq section that sets no `contextWindow` resolves to 131072 and is checked at that. - Provider names in a per-provider pin or cap are canonicalised like provider ids (`google` is `gemini`). One that names no registered provider and is not in `allowedProviders` is an `[unknownProvider]` violation, so a typo cannot leave a provider unpinned. See `modules/config-policy/README.md` for the rest of the module. ### Troubleshooting OpenAI-compatible endpoints **404 on every request - wrong `baseUrl`.** The client appends `/chat/completions` to `baseUrl` and nothing else, so `baseUrl` must stop exactly where the vendor's path to `/chat/completions` begins. Missing `/v1` (`http://localhost:8000` for vLLM) and a doubled one (`.../v1/v1/chat/completions`, from a `baseUrl` of `.../v1/chat/completions` or with `/v1` added twice) both return 404. Vendor paths differ: `/openai/v1` for Groq, `/inference/v1` for Fireworks, no `/v1` at all for Perplexity Sonar. Model listing (`/models`) fails the same way, which makes it a quick check. **401 or 403 - authentication.** Surfaced as an `AuthenticationError`. Usually the key is not reaching the request: `apiKey = ${?GROQ_API_KEY}` with the variable unset leaves `apiKey` out, and the request goes without an `Authorization` header. Check the variable is exported in the process that runs your app. Otherwise the key belongs to another vendor or project, or - for NVIDIA - is the `NGC_API_KEY` used for pulling containers. For a local server started with `--api-key`, set the same key as `apiKey`. **Model not found.** Vendors answer an unknown model with a 404 or a 400. Model ids must match exactly, prefix included (`openai/gpt-oss-120b` on Groq, `accounts/fireworks/models/...` on Fireworks), and hosted catalogues retire models regularly. List what the endpoint serves, with the section's key and headers: ```scala import org.llm4s.config.Llm4sConfig Llm4sConfig.listModels("groq-main") // GET /models ``` **No API key for a local server.** Leave `apiKey` out - do not set a placeholder. OpenAI's SDKs require some key, which is why vendor examples show `api_key="ollama"` or `"not-used"`; llm4s does not, and without one it sends no `Authorization` header at all. A 401 from a local server means it was started with a key (`--api-key` for vLLM and `llama-server`, "Require Authentication" in LM Studio), and `apiKey` must then match it. **Missing `baseUrl`.** A section without one fails to load with `baseUrl: set it in application.conf under llm4s.providers..baseUrl (e.g. http://localhost:8000/v1; to read it from OPENAI_COMPATIBLE_BASE_URL, add baseUrl = ${?OPENAI_COMPATIBLE_BASE_URL} to the section)`. `Llm4sConfig` reads no variable for it by itself: set `baseUrl` in the section, from a variable if you like - `baseUrl = ${?OPENAI_COMPATIBLE_BASE_URL}` binds the conventional one. If the section binds a variable and still fails this way, the variable is not set in the process that runs your app. **Streaming fails with a 400 or 422 naming `stream_options`, while `complete` works.** The endpoint rejects fields it does not know. Set `streamUsage = false` in the section (see [streamed token usage](#what-the-generic-path-does-and-does-not-do)); the stream then carries usage only if the server sends it unasked. **Replies cut short, or context errors.** Without a `contextWindow`, the generic provider uses the model registry's window when it knows the model (see [where the context window comes from](#where-the-context-window-comes-from)), and otherwise 8192. Set it to the model's real window, or the server's configured context for a local server. ### Provider docs The sources for the values above: - Groq: [OpenAI compatibility](https://console.groq.com/docs/openai), [models](https://console.groq.com/docs/models), [deprecations](https://console.groq.com/docs/deprecations), [reasoning](https://console.groq.com/docs/reasoning) - Together AI: [OpenAI compatibility](https://docs.together.ai/docs/openai-api-compatibility), [serverless models](https://docs.together.ai/docs/serverless-models) - Fireworks AI: [OpenAI compatibility](https://docs.fireworks.ai/tools-sdks/openai-compatibility), [quickstart](https://docs.fireworks.ai/getting-started/quickstart), [gpt-oss-120b](https://fireworks.ai/models/fireworks/gpt-oss-120b) - xAI: [quickstart](https://docs.x.ai/developers/quickstart), [Chat Completions (legacy)](https://docs.x.ai/developers/model-capabilities/legacy/chat-completions), [models](https://docs.x.ai/developers/models), [reasoning](https://docs.x.ai/developers/model-capabilities/text/reasoning) - NVIDIA NIM: [hosted LLM APIs](https://docs.api.nvidia.com/nim/reference/llm-apis), [Llama 3.3 70B Instruct](https://docs.api.nvidia.com/nim/reference/meta-llama-3_3-70b-instruct), [self-hosted quickstart](https://docs.nvidia.com/nim/large-language-models/latest/get-started/quickstart.html), [self-hosted API reference](https://docs.nvidia.com/nim/large-language-models/latest/reference/api-reference.html) - Perplexity: [Sonar OpenAI compatibility](https://docs.perplexity.ai/docs/sonar/openai-compatibility), [Sonar Pro](https://docs.perplexity.ai/docs/sonar/models/sonar-pro), [migrating from Sonar](https://docs.perplexity.ai/docs/agent-api/migrate-from-sonar/overview), [Router API](https://docs.perplexity.ai/docs/router/quickstart), [Router models](https://docs.perplexity.ai/docs/router/models) - OrcaRouter: [quickstart](https://docs.orcarouter.ai/getting-started/quickstart), [reasoning](https://docs.orcarouter.ai/advanced/reasoning) - vLLM: [OpenAI-compatible server](https://docs.vllm.ai/en/latest/serving/online_serving/openai_compatible_server/), [`vllm serve`](https://docs.vllm.ai/en/latest/cli/serve/), [tool calling](https://docs.vllm.ai/en/latest/features/tool_calling/) - LM Studio: [OpenAI compatibility](https://lmstudio.ai/docs/developer/openai-compat), [authentication](https://lmstudio.ai/docs/developer/core/authentication) - llama.cpp: [`llama-server` README](https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md) - Ollama: [OpenAI compatibility](https://docs.ollama.com/api/openai-compatibility) --- ## Mistral From the release after `0.4.1`, Mistral ships in `llm4s-openai-compatible`: Mistral's API is the OpenAI `/v1/chat/completions` format, so it runs on the shared client with a small dialect. ```scala libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion ``` In `0.4.1` and earlier it is part of `llm4s-core`, and does not stream ([#925](https://github.com/llm4s/llm4s/issues/925)). ### Setup 1. **Get API key** from [console.mistral.ai](https://console.mistral.ai/api-keys) 2. **Export the key** - `llm4s-openai-compatible` binds it to `llm4s.credentials.mistral.apiKey`: ```bash export MISTRAL_API_KEY=your-key ``` ### Configuration ```hocon llm4s { providers { provider = "mistral-main" mistral-main { provider = "mistral" model = "mistral-large-latest" # apiKey defaults to MISTRAL_API_KEY # baseUrl defaults to https://api.mistral.ai - the API root; requests go to # /v1/chat/completions, and a baseUrl already ending in /v1 is used as it is } } } ``` ### What it supports Chat, **streaming** (text, tool calls and token usage), **tool calling**, structured output (`ResponseFormat.Json` and `ResponseFormat.JsonSchema`), model listing and exchange logging. - **Tool-call ids.** Mistral accepts only nine-letter-or-digit ids. Its own pass through; any other id in the conversation (from another provider, say) is mapped to a stable nine-character one, the same for the call and its answer. - **Reasoning models** (Magistral) return their reasoning as `Completion.thinking`, and stream it as thinking deltas. - `CompletionOptions.reasoning` is not sent: only some Mistral models accept `reasoning_effort`. --- ## Cohere From the release after `0.4.1`, Cohere ships in `llm4s-openai-compatible` and calls Cohere's [OpenAI-compatibility API](https://docs.cohere.com/docs/compatibility-api) (`https://api.cohere.ai/compatibility/v1`) rather than its native v2 `/v2/chat`: ```scala libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion ``` In `0.4.1` and earlier it is part of `llm4s-core`, calls `/v2/chat`, sends text only and does not stream ([#925](https://github.com/llm4s/llm4s/issues/925)). ### Setup 1. **Get API key** from [dashboard.cohere.com](https://dashboard.cohere.com/api-keys) 2. **Export the key** - bound to `llm4s.credentials.cohere.apiKey`, which the Cohere reranker in `llm4s-rag` shares, so one variable serves both. (Cohere's Python SDK prefers `CO_API_KEY`; llm4s reads `COHERE_API_KEY` only, the name it has always used.) ```bash export COHERE_API_KEY=your-key ``` ### Configuration ```hocon llm4s { providers { provider = "cohere-main" cohere-main { provider = "cohere" model = "command-a-03-2025" # apiKey defaults to COHERE_API_KEY # baseUrl defaults to https://api.cohere.ai/compatibility/v1 } } } ``` A `baseUrl` naming Cohere's native API root, as configs written for `0.4.1` do (`https://api.cohere.com`, with or without `/v1` or `/v2`), is mapped to `/compatibility/v1` and logged; a proxy root is treated the same way. A `baseUrl` already ending in `/compatibility/v1` is used as it is. ### What it supports Chat, **streaming** (text, tool calls and token usage), **tool calling**, structured output and exchange logging. System messages are sent under the `developer` role, and a JSON schema as `{"type": "json_object", "schema": ...}`, as Cohere's compatibility API documents. `CompletionOptions.reasoning` is not sent. Cohere's reranker (`llm4s-rag`) is separate and unchanged. ### Available Models `command-a-03-2025` and the other Command models your key can use; see [Cohere's model list](https://docs.cohere.com/docs/models). --- ## xAI (Grok) xAI's Grok models, through xAI's OpenAI-compatible Chat Completions API (`https://api.x.ai/v1/chat/completions`). The provider ships in `llm4s-openai-compatible` as a dialect on the shared client ([#1023](https://github.com/llm4s/llm4s/issues/1023)), and is Beta: ```scala libraryDependencies += "org.llm4s" %% "llm4s-openai-compatible" % llm4sVersion ``` ### Setup 1. **Get API key** from [console.x.ai](https://console.x.ai) 2. **Export the key** - `llm4s-openai-compatible` binds it to `llm4s.credentials.xai.apiKey`: ```bash export XAI_API_KEY=xai-... ``` ### Configuration ```hocon llm4s { providers { provider = "xai-main" xai-main { provider = "xai" model = "grok-4.7" # apiKey defaults to XAI_API_KEY # baseUrl defaults to https://api.x.ai/v1 } } } ``` The context window comes from the model registry when it lists the model, and otherwise from xAI's published windows: 500k for `grok-4.5` to `grok-4.7`, 1M for `grok-4.3` and `grok-4.20`, 256k for the other Grok 4 models and 131072 for older ones. ### What it supports Chat, **streaming** (text, tool calls and token usage), **tool calling**, images in user messages, structured output, model listing (`/v1/models`) and exchange logging. - **Reasoning output.** A reply's `reasoning_content`, where the model returns it (Grok 3 mini does), is `Completion.thinking`, and streams as thinking deltas. Grok 4.5 and later keep their reasoning encrypted and return it only on xAI's Responses API, so on Chat Completions they return none. Earlier turns' thinking is not sent back: Chat Completions has no field for it. - **Reasoning tokens** are `TokenUsage.thinkingTokens`, from `usage.completion_tokens_details.reasoning_tokens`. xAI counts them outside `completion_tokens` and inside `total_tokens`. - **Reasoning effort.** `CompletionOptions.reasoning` is sent as `reasoning_effort` where the model takes it. xAI's reasoning models cannot stop reasoning, so `None` sends the lowest level, and the first such request in a process logs a warning: | Model | `ReasoningEffort.None` | `Low` | `Medium` | `High` | |---|---|---|---|---| | `grok-4.5`, `grok-4.6`, `grok-4.7` (and their `-latest` aliases) | `low` | `low` | `medium` | `high` | | `grok-3-mini` (and variants) | `low` | `low` | `high` | `high` | | any other model | not sent | not sent | not sent | not sent | `high` is the default on Grok 4.5 and later; `xhigh` (4.6 and later) is not reachable from `CompletionOptions`. `grok-4` rejects the parameter, and on `grok-4.20-multi-agent` it sets the number of agents, so neither is sent it. With `reasoning` unset nothing is sent. - **Penalties.** xAI rejects `presence_penalty`, `frequency_penalty` and `stop` on reasoning models. llm4s never sends `stop`; a non-zero `presencePenalty` or `frequencyPenalty` is left out of a request to a reasoning model (logged once), and sent as given to a `-non-reasoning` model. - xAI calls Chat Completions its **legacy** endpoint; new features ship on its Responses API, which this provider does not speak. ### Available Models `grok-4.7` and the other models your key can use; see [xAI's model list](https://docs.x.ai/developers/models). --- ## Ollama (Local Models) From the release after `0.4.1`, Ollama support - chat and embeddings - ships in its own artifact, alongside `llm4s-core`: ```scala libraryDependencies += "org.llm4s" %% "llm4s-ollama" % llm4sVersion ``` In `0.4.1` and earlier it is part of `llm4s-core`. See the [installation guide](../getting-started/installation#for-ollama-local-models). ### Setup 1. **Install Ollama** from [ollama.ai](https://ollama.ai) 2. **Pull a model:** ```bash ollama pull mistral # Downloads model ollama serve # Runs on http://localhost:11434 ``` 3. **Add a named section** to `application.conf` and select it as the default. No API key is needed, and `baseUrl` defaults to `http://localhost:11434`, where `ollama serve` listens. ### Configuration In `application.conf`: ```hocon llm4s { providers { provider = "ollama-local" ollama-local { provider = "ollama" model = "mistral" # baseUrl = "http://gpu-box:11434" # optional: the default is http://localhost:11434 # baseUrl = ${?OLLAMA_BASE_URL} # optional: let OLLAMA_BASE_URL override the default } } } ``` ### Structured output `llm4s-ollama` honours `CompletionOptions.responseFormat` (and so `completeStructured`) through the `format` field of `/api/chat`, for streaming and non-streaming calls alike: `ResponseFormat.Json` sends `"format": "json"`, and `ResponseFormat.JsonSchema` sends the schema object, which needs Ollama 0.5 or later. `JsonSchema.name` and `strict` have no Ollama equivalent and are ignored. With no `responseFormat`, no `format` field is sent. ### Available Models 100+ models available: - **Small:** `phi`, `neural-chat` (~4GB) - **Medium:** `mistral`, `llama2` (~13GB) - **Large:** `llama2-70b` (~40GB) - **Specialized:** `neural-chat`, `orca`, `wizard-math` Run `ollama list` to see installed models. ### Costs Free! Just compute (CPU or GPU needed). ### Tips - Perfect for development and testing - Works offline (no internet needed) - Use GPU for faster inference - Ideal for sensitive data (runs locally) --- ## Thinking in conversation history A model's reasoning is returned on the message it produced, as `AssistantMessage.thinking` - a sequence of `ThinkingBlock`s - and `Completion.thinking` is that message's thinking text. Because it lives on the message, it stays in the conversation (and in an agent's thread) like the content and tool calls do, and each client sends it back where its provider takes it: | Provider | Read from | Sent back as | |---|---|---| | Anthropic | `thinking` blocks (each with its `signature`) and `redacted_thinking` | the same blocks, unchanged and first in the assistant turn | | Bedrock (Converse) | `reasoningContent` (text with `signature`, or `redactedContent`) | the same blocks, unchanged and first in the assistant turn | | Ollama | `message.thinking` | `thinking` on the assistant message | | DeepSeek, Z.ai | `reasoning_content` | `reasoning_content` | | OpenRouter | `reasoning` / `thinking`, and `reasoning_details` | `reasoning`, and `reasoning_details` unchanged (same items, order and fields) | | Mistral (Magistral) | thinking chunks in `content` | a thinking chunk before the text chunk | | Gemini API, Vertex AI | a `thoughtSignature` on a part: the `functionCall` part, or the last text part (and thought summaries, `thought: true`, as text) | the signature, on the same part ([below](#gemini-and-vertex-ai-thought-signatures)) | | OpenAI, Azure, Cohere, generic `openai-compatible` | - | not sent: the API has no field for it | | IBM watsonx.ai | `reasoning_content` (or `reasoning`) | not sent: the chat API's assistant message has no field for it | Anthropic and Bedrock require the signed blocks back when a thinking model's turn ends in tool calls, and reject a thinking block without a signature, so unsigned thinking - from another provider earlier in the conversation, or set by hand with `withThinking(text)` - is left out of their requests. The Anthropic client sends tool calls and their results as `tool_use` and `tool_result` blocks for the same reason: the thinking has to sit in the turn that made the calls. Both clients send a call only when its result is in the run of tool messages straight after it, as those APIs require; a result anywhere else (after a user message, say) goes as `[Tool result for ]: ...` text, and its call is left out. OpenRouter returns `reasoning_details` when the underlying model's reasoning is signed, summarised or encrypted (Claude, Gemini, OpenAI reasoning models), and [requires the whole sequence back unchanged](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens#preserving-reasoning) when a conversation continues after tool calls. Each item is kept, with every field, as a `ThinkingBlock.Opaque("openrouter", json)` block after the reasoning text - streamed items are joined by `index` first - and only the OpenRouter client sends them back; every other client ignores opaque blocks that are not its own. ### Gemini and Vertex AI thought signatures A thinking Gemini model attaches an opaque, base64 `thoughtSignature` to a part of its turn and expects it back on the same part. Google's [Vertex AI guide](https://cloud.google.com/vertex-ai/generative-ai/docs/thought-signatures) states the rules these clients follow: - A response with `functionCall` parts needs its signature back: Gemini 3 models answer HTTP 400 when a required signature is missing. With parallel calls only the first `functionCall` part carries one; across sequential steps each step's first call does. The part goes back "exactly as it was returned". - A response without function calls may carry a signature on its last part (streaming can deliver it on a part with empty text). Sending it back is recommended, and leaving it out is not an error. - A part with a signature is never merged with one without. Each signature is kept as a sealed `ThinkingBlock.Opaque` block of the client's provider id (`gemini` or `vertexai`) and goes back only to the provider and model that produced it. Unlike Anthropic's prefix rule, the binding is to that origin alone: Google asks for signatures to be preserved when history is modified or trimmed, and Gemini 3 answers HTTP 400 when the current turn's function-call signature is missing, so pruning or compressing earlier turns leaves them in place. What unseals one is a change of provider or model, or an edit to the message that carries it. A function call's signature is stored against the tool call's id with the `functionCall` payload exactly as Gemini returned it, and the part is replayed verbatim (its optional `id` kept, `args` present or absent as received); a populated `functionCall.id` becomes the tool call's own id and the matching `functionResponse` echoes it. A text part's signature records its character offset in the message content, and the content is split there when the turn is sent, so a signed part is never merged with an unsigned one (if the content no longer fits, the text signature is left out). Parts are rebuilt as text first, then function calls, as these clients have always sent them. Google's documentation does not say whether a signature from the Gemini API validates on Vertex AI, so the two clients are separate signing authorities: a conversation moved from one to the other keeps its text and drops the signatures, never sending a foreign one. A signature on an image part, or on a thought-summary part, is not kept, since these clients neither request nor send those parts. Signed, redacted and opaque thinking is *sealed*: it is valid only in the conversation it was produced in. [Anthropic](https://platform.claude.com/docs/en/build-with-claude/preserved-thinking) validates a thinking block against everything sent before it - the top-level system prompt, the tools and every earlier message - and rejects it (400) once any of those changes; a block must also come back unchanged, beside the content and tool calls it came with. Bedrock documents its reasoning signature as a hash of all the messages in the conversation. llm4s enforces both halves with two rules (`hasSealedThinking` reports which state a message is in): - **The message itself.** `withContent` and `withToolCalls`, given a changed value, *unseal* the thinking: they drop redacted and opaque blocks and signatures and keep the reasoning text. An agent's `afterAgent` answer rewrite and a tool-call edit go through those setters, and so do the Anthropic and Bedrock clients when they leave out an unpaired call. - **Everything before it.** When Anthropic, Bedrock or OpenRouter returns sealed thinking, the client records a fingerprint of the request on the message (`AssistantMessage.thinkingBinding`): every system message, the tools, the response format and every earlier message in order, system messages in their positions (OpenRouter sends them inline, so moving one changes what came before), as sent. When the message is sent again, the client replays its sealed thinking only if the conversation before it still has that fingerprint, and sends it unsealed otherwise. The check runs at the point of sending, so it covers every rewrite of the history - `ContextPruning` and the agent's context-window middleware, `TokenWindow` trimming, the context compressors (`DeterministicCompressor`, `LLMCompressor`, `HistoryCompressor`, `ToolOutputCompressor`, `ContextManager`), a handoff's view of the thread, memory or RAG context inserted before existing turns, a changed system prompt or tool set, or a hand edit - without any of them having to know about thinking. - **Who produced it.** The fingerprint also covers the provider id and model that produced the thinking, and the client checks it against the provider and model it is about to call. A signature or reasoning item belongs to its producer, so a conversation produced by one client and continued with another - Bedrock then Anthropic, or one OpenRouter model then another - is sent unsealed, never with a foreign signature. Every client binds to the provider and model it is configured with, never the model a response reports: Anthropic and Bedrock may report an alias's resolved snapshot, and a router such as `openrouter/auto` (or OpenRouter's model fallbacks) reports the model it chose for that request. The provider that resolved the alias or chose the route is the one the thinking goes back to, and OpenRouter [requires](https://openrouter.ai/docs/guides/best-practices/reasoning-tokens#preserving-reasoning-blocks) the complete `reasoning_details` sequence on a tool-call continuation, so routed turns keep it. Changing the configured model unseals every earlier turn. The endpoint is not part of it: a proxy or regional endpoint in front of the same provider changes nothing the provider checks. ### Moving between Anthropic and Bedrock A conversation that moves between the Anthropic API and Bedrock (a failover, say) has its signed thinking unsealed: the text is kept, the signatures and redacted blocks are dropped, and nothing is rejected. Anthropic [documents](https://platform.claude.com/docs/en/build-with-claude/thinking#thinking-encryption) that signature values are compatible across platforms (the Claude API, Amazon Bedrock and Google Cloud), but that is only one of the conditions for a replayed block to be accepted. The API also checks the model that produced it (each model reads its own thinking blocks and those of a fixed set of other models) and that everything sent before it is unchanged, and rejects the request with a 400 or drops the block when either fails. The two clients name the same model differently (`claude-sonnet-4-5-20250929` against `anthropic.claude-sonnet-4-5-20250929-v1:0`) and serialise the system prompt and tools through different APIs, so this library cannot show that the prefix a block was signed over is the prefix the other platform sees. Losing the reasoning context on a failover is safe; a rejected request is not, so each remains its own signing authority. Treating them as one would need a mapping between the two model ids and a live check that a signature survives the move. ### Limits that remain - **OpenRouter routers and fallbacks.** OpenRouter's documentation says dynamic routers (`openrouter/auto`, `openrouter/free`) omit the reasoning field, so there is nothing to replay for them, and that the `reasoning_details` sequence must match the original output with nothing rearranged or modified. It does not say what happens to the details when a fallback sends the request to a different model, so they are sent back unchanged, as its documentation asks. - **Editing a tool call on a turn that is still in progress** (a human-in-the-loop edit) unseals that turn's thinking, because the signature covers the tool call. Anthropic's manual extended thinking requires the final assistant turn of a thinking-enabled request to begin with a thinking block, so such a request can be rejected; adaptive thinking drops that requirement. Gemini 3 answers 400 for a missing function-call signature in the current turn. Earlier turns whose history is unchanged keep their sealed thinking: Anthropic recommends passing all thinking blocks back, keeps them in context on newer models, and accepts any unbroken run of the original blocks. A change unseals every turn after it and none before it, so the replayed blocks never have a gap. Unsealed thinking keeps its text, which Anthropic and Bedrock leave out and other providers still receive. Sealed thinking with no binding - built by hand with `withThinking` - is not replayed. To keep signed thinking, append to the conversation and leave what is already there as it is. Thinking also counts toward token estimates (`ConversationTokenCounter`), since most providers resend it. ```scala import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ Completion, CompletionOptions, Conversation, ToolCall, ToolMessage } import org.llm4s.types.Result def toolTurn( client: LLMClient, conversation: Conversation, options: CompletionOptions, runTools: Seq[ToolCall] => Seq[ToolMessage] // your tool execution ): Result[Completion] = for { first <- client.complete(conversation, options) // a tool-call turn, with thinking results = runTools(first.message.toolCalls) next = conversation.addMessage(first.message).addMessages(results) // keeps the thinking answer <- client.complete(next, options) // the provider gets it back } yield answer ``` --- ## Citations and grounding sources A model that searches the web by itself reports the sources behind its answer. llm4s returns them on `Completion.citations`, a `List[Citation]` that is empty for every completion that has none: ```scala import org.llm4s.llmconnect.model.Completion def sources(completion: Completion): List[String] = completion.citations.map(c => s"${c.title.getOrElse(c.url)} <${c.url}>") ``` A `Citation` has a `url`, always, and these fields, each `None` when the provider did not send it: | Field | Meaning | |---|---| | `title` | the source's title | | `citedText` | a passage of the source that came with the citation (OpenRouter's `content`); it describes the source, not the answer | | `startIndex`, `endIndex` | where the inline citation sits, as the provider reports it: positions in `Completion.content`. OpenAI documents them as the first and last character "of the URL citation in the message"; no provider documents them as the span of prose the source supports | Take the two indices as the location of the citation, not of the supported claim, and check the provider's convention before cutting `content` with them (whether `end_index` is inclusive is the provider's call). `hasSpan` says whether both are present. | Provider | Citations | |---|---| | OpenAI (search models for Chat Completions), Azure, Requesty | Read from the message's `url_citation` annotations (`url`, `title`, `start_index`, `end_index`). Search models (`gpt-5-search-api`; the `*-search-preview` models were retired on 2026-07-23) return them with no request option; Azure and Requesty return them only if the deployment does. | | OpenRouter (`:online` models, or the web plugin) | The same annotations, plus `content` as `citedText`. | | DeepSeek, Z.ai, Mistral, Cohere, a generic `openai-compatible` endpoint | Read if the reply carries standard `url_citation` annotations; none of them is documented to. | | Gemini, Vertex AI | With `googleSearch = true` in the section (see [Google Search grounding](#google-search-grounding)): one citation per source in the candidate's `groundingMetadata.groundingChunks` - `url` and `title`, and for a Vertex AI Search / RAG source (`retrievedContext`) its text as `citedText`. No indices: Gemini's `groundingSupports` are UTF-8 byte offsets of the supported text, not where a citation sits. Without the setting, empty. Read from `complete` and from a stream. | | Anthropic | With `webSearch = true` in the section (see [Web search](#web-search)): one citation per source a text block cites - for a `web_search_result_location` its `url`, `title` and `cited_text`, for a `search_result_location` its `source` as `url`. Document citations (`char_location`, `page_location`, `content_block_location`) name a document, not a URL, and are skipped. No indices: Anthropic attaches a citation to a text block, not to a position in it. Read from `complete` and from a stream (`citations_delta`). | | Ollama, Bedrock, watsonx | Empty. | Things to know: - **Streaming.** For OpenAI and OpenRouter, a completion assembled from streamed chunks has no citations: neither documents where a stream carries them, so streamed chunks are not read. Gemini and Vertex AI send `groundingMetadata` in a streamed chunk (usually the last), and the streamed completion carries it. Anthropic streams each citation as a `citations_delta`, and the streamed completion carries them. (A model that does not stream natively is answered with one ordinary call, and that completion keeps its citations.) - **A citation never fails a reply.** One without a `url` is dropped, a field of the wrong type is read as absent, and the answer is returned either way. The OpenAI client treats an `annotations` list the SDK cannot parse at all as no citations; the OpenAI-compatible client keeps the entries it can read. - **Not read:** Perplexity's top-level `citations` list (its provider is tracked in #1026). - The shapes come from the OpenAI Java SDK's `url_citation` types and OpenRouter's documentation. No live provider was called while writing this. --- ## Enterprise authentication Many organisations forbid long-lived API keys for cloud models and require the platform's own identity instead: a managed identity, a workload identity or an IAM role. Each of the three clouds works that way in llm4s, with no key in `application.conf`. | Cloud | Provider | How it authenticates without a key | |---|---|---| | Microsoft Azure | `azure` (`llm4s-openai` + `llm4s-azure-identity`) | `authentication = "entra-id"`: Microsoft Entra ID tokens from `DefaultAzureCredential` | | AWS | `bedrock` (`llm4s-bedrock`) | The AWS default credentials chain: environment, `~/.aws` profile, an instance, task or IRSA role | | Google Cloud | `vertexai` (`llm4s-gemini`) | Application Default Credentials: `GOOGLE_APPLICATION_CREDENTIALS`, gcloud's login, the metadata server | ### Azure OpenAI with Microsoft Entra ID Azure Policy can disable key ("local") authentication on an Azure OpenAI resource. Such a resource takes Microsoft Entra ID tokens instead, and so does llm4s ([#2274](https://github.com/llm4s/llm4s/issues/2274)). Add `llm4s-azure-identity` beside `llm4s-openai`: ```scala // build.sbt - same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-azure-identity" % llm4sVersion ``` and set `authentication = "entra-id"` in the section, with no `apiKey`: ```hocon llm4s.providers { provider = "azure-prod" azure-prod { provider = "azure" endpoint = "https://my-resource.openai.azure.com" model = "gpt-4o" # the deployment name authentication = "entra-id" # clientId = ${?AZURE_CLIENT_ID} # a user-assigned managed identity } } ``` The token comes from Azure's `DefaultAzureCredential`, which tries, in turn, environment credentials (`AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and a secret or certificate), workload identity on AKS, a managed identity, and a developer's `az login`. It is requested for the `https://cognitiveservices.azure.com/.default` scope, cached, and fetched again five minutes before it expires, so no request carries an expired token; one the service rejects with a 401 is dropped, and the next request fetches a new one. The identity needs the **Cognitive Services OpenAI User** role on the resource. Such a section takes no API key: setting `apiKey` as well is an error, and the shared `AZURE_OPENAI_API_KEY` is not used. Without `llm4s-azure-identity` on the classpath, loading the section fails with a configuration error naming it. `llm4s-azure-identity` brings Azure's `azure-identity` (with `azure-core`, MSAL4J, Netty and Reactor), which is why it is a module of its own: `llm4s-openai` alone puts no Azure SDK on your classpath. In code, any `AccessTokenProvider` authenticates a config, and `AzureEntraId` makes one from Azure's `DefaultAzureCredential` or from any other `TokenCredential`: ```scala import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.auth.azure.AzureEntraId import org.llm4s.llmconnect.config.AzureConfig import org.llm4s.model.ModelRegistryService def azureClient(using ModelRegistryService) = val tokens = AzureEntraId.defaultCredential() // or AzureEntraId.tokenProvider(anyTokenCredential) LLMConnect.getClient(AzureConfig.keyless("https://my-resource.openai.azure.com", "gpt-4o", tokens)) ``` Embeddings take the same tokens: `OpenAIEmbeddingProvider.fromConfig(config, tokens)`, with the resource's v1 API as the base URL (`https://my-resource.openai.azure.com/openai/v1`) and the embedding deployment's name as the model. From Java, `Llm4s.azureOpenAi().entraId()` (or `.entraId(clientId)`, or `.tokenProvider(tokens)`) replaces `.apiKey(...)`. With the Spring Boot starter, the properties are the section's keys: `llm4s.providers.azure-prod.authentication=entra-id` and `llm4s.providers.azure-prod.client-id=...`. ### AWS Bedrock with IAM roles A `bedrock` section that sets no `accessKeyId` uses the AWS SDK's default credentials chain: the `AWS_*` environment variables, a `profile` in `~/.aws`, and the role of the EC2 instance, ECS task or EKS service account (IRSA) the application runs as. See the [installation guide](../getting-started/installation#for-aws-bedrock). ### Vertex AI with Application Default Credentials A `vertexai` section with no `apiKey` uses Application Default Credentials: `GOOGLE_APPLICATION_CREDENTIALS`, then gcloud's `application_default_credentials.json`, then the metadata server of the GCE, GKE or Cloud Run workload. See [Google Vertex AI](#google-vertex-ai). --- ## API Key Management ### Security Best Practices **Never commit API keys!** 1. **Keep keys in environment variables.** The vendor's own variable is enough - each provider module binds it to the vendor's shared key: ```hocon openai-main { provider = "openai", model = "gpt-4o" } ``` ```bash export OPENAI_API_KEY=sk-... export ANTHROPIC_API_KEY=sk-ant-... ``` For a second account, or in production where each section should say which account it bills, bind the section's own key: `apiKey = ${?OPENAI_BATCH_API_KEY}`. The config-policy `prod` preset flags sections without one. llm4s logs at INFO which path each key came from, never the value. 2. **Use .env file** (add to `.gitignore`): ```bash # .env (NOT committed to git) OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-ant-... ``` 3. **Use CI/CD secrets:** ```yaml # GitHub Actions - uses: actions/setup-java@v3 env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} ``` 4. **Rotate keys regularly** on provider dashboards ### Using Keys Safely in Code **Good:** ```scala import org.llm4s.config.Llm4sConfig // Keys come from the vendor's variable or the section's own ${?VAR} binding - never hardcoded val providerConfig = Llm4sConfig.defaultProvider() ``` **Bad:** ```scala // ❌ Never do this! val key = "sk-proj-abc123..." // Hardcoded sys.env.get("OPENAI_API_KEY") // Outside config boundary ``` --- ## Base URL Customization ### When to Use Custom Base URLs - **Reverse proxy** or load balancer - **VPC endpoint** for security - **Azure OpenAI** or self-hosted setup - **Provider migration** (e.g., from OpenAI to similar API) ### Setting Custom URLs A base URL is the `baseUrl` key of the named section (`endpoint` for Azure OpenAI). No `_BASE_URL` variable is read by itself; bind one if you want the environment to override the URL: ```hocon llm4s { providers { provider = "openai-main" openai-main { provider = "openai" model = "gpt-4o" baseUrl = "https://proxy.example.com/openai" baseUrl = ${?OPENAI_BASE_URL} # optional: overrides the line above when set } } } ``` In HOCON the later line wins, and `${?VAR}` with the variable unset leaves the earlier value in place. Defaults when `baseUrl` is omitted: | Provider | Default `baseUrl` | |----------|-------------------| | `openai` | `https://api.openai.com/v1` | | `anthropic` | `https://api.anthropic.com` | | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | | `deepseek` | `https://api.deepseek.com` | | `mistral` | `https://api.mistral.ai` | | `cohere` | `https://api.cohere.ai/compatibility/v1` (a native root is mapped to `/compatibility/v1`) | | `openrouter` | `https://openrouter.ai/api/v1` | | `zai` | `https://api.z.ai/api/paas/v4` | | `requesty` | `https://router.requesty.ai/v1` | | `ollama` | `http://localhost:11434` | | `openai-compatible` | none - required | --- ## Provider Comparison Table | Feature | OpenAI | Anthropic | Gemini | Azure | DeepSeek | Cohere | Ollama | OpenAI-compatible | |---------|--------|-----------|--------|-------|----------|--------|--------|-------------------| | **Setup Difficulty** | Easy | Easy | Easy | Hard | Easy | Easy | Medium | Easy | | **API Key Required** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | Hosted ✅, local ❌ | | **Free Tier** | Limited | Limited | ✅ Generous | ❌ | Limited | Limited | ✅ | Endpoint-specific | | **Local Option** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ vLLM, LM Studio, llama.cpp | | **Context Window** | 128K | 200K | 1M | 128K | 4K-32K | 8K | Model-specific | Set in config (default 8K) | | **Vision Support** | ✅ | ✅ | ✅ | ✅ | ⚠️ Limited | ❌ | Model-specific | ❌ Text only | | **Function Calling** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ If the model supports it | ✅ If the endpoint supports it | | **Reasoning Models** | ✅ o1 | ❌ | ❌ | ✅ (via OpenAI) | ✅ deepseek-reasoner | ❌ | ❌ | ⚠️ Run, but reasoning not configured or read | | **Enterprise Support** | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | N/A | Endpoint-specific | | **Cost (Budget)** | Medium | Medium | 🏆 Low | High | 🏆 Very Low | Low | Free | Endpoint-specific | | **Speed** | Fast | Medium | 🏆 Very Fast | Medium | Fast | Medium | Varies | Endpoint-specific | | **Reliability** | 🏆 Enterprise | 🏆 Enterprise | Good | 🏆 Enterprise | Good | Good | Local | Endpoint-specific | ### Which Provider Should I Use? - **Getting started?** → Try **Gemini** (free tier) or **Ollama** (local) - **Production API?** → **OpenAI** (most stable) or **Anthropic** (best reasoning) - **Cost-conscious?** → **DeepSeek** or **Ollama** - **Enterprise?** → **Azure OpenAI** or **Anthropic** - **Private data?** → **Ollama** (runs locally) - **xAI (Grok)?** → [**xAI**](#xai-grok) - **Groq, Together, Fireworks, NVIDIA NIM, or a local vLLM, LM Studio or llama.cpp server?** → the generic [**OpenAI-compatible**](#openai-compatible-endpoints) provider - **Reasoning tasks?** → **Anthropic Claude** or **DeepSeek reasoner** - **Vision/multimodal?** → **OpenAI GPT-4o** or **Anthropic Claude** --- ## Multiple Providers in One App Configure one named section per provider, then load the default or any section by name: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService // conversation: the Conversation to send for { registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry primaryConfig <- Llm4sConfig.defaultProvider() // llm4s.providers.provider fallbackConfig <- Llm4sConfig.provider("claude") // llm4s.providers.claude primary <- LLMConnect.getClient(primaryConfig) fallback <- LLMConnect.getClient(fallbackConfig) } yield primary.complete(conversation).orElse(fallback.complete(conversation)) ``` Each section is validated when it is loaded, so here both must have a key available; a problem in either surfaces as the `Left` of this `for`. See [Switching providers](../getting-started/configuration#switching-providers). This enables: - **Fallback logic** - Use OpenAI, fall back to Anthropic - **A/B testing** - Compare provider outputs - **Cost optimization** - Use cheapest available provider --- ## Troubleshooting ### "Invalid API Key" ```bash # Verify the vendor's variable is set echo $OPENAI_API_KEY # ...and that the section sets no apiKey of its own, which would win over it. # The INFO log line "llm4s.providers.: API key from " says which was used. # Check key format (starts with correct prefix) # OpenAI: sk-proj-* or sk-* # Anthropic: sk-ant-* # Gemini: Should be long alphanumeric ``` ### "Connection refused" For local providers (Ollama): ```bash # Check if Ollama is running curl http://localhost:11434/api/tags # Start Ollama ollama serve ``` ### "Model not found" Check the `model` key of the section you selected, and that its `provider` is the one serving that model (`model = "gpt-4o"` under `provider = "openai"`; no `openai/` prefix): ```bash # Check available models # OpenAI: https://platform.openai.com/docs/models # Anthropic: https://docs.anthropic.com/claude/reference/models ``` ### "Rate limit exceeded" Use provider-specific strategies: - OpenAI: Wait before retrying, use batching API - Gemini: Upgrade from free tier - Ollama: Increase system resources or use GPU --- Source: https://llm4s.org/guide/vector-store # Vector Store Low-level vector storage abstraction for RAG and semantic search. --- ## Overview The `VectorStore` trait provides a backend-agnostic interface for storing and searching vector embeddings. This is the foundation layer for building RAG (Retrieval-Augmented Generation) applications. **Key Features:** - Backend-agnostic API supporting multiple vector databases - Type-safe error handling with `Result[A]` - Metadata filtering with composable DSL - Batch operations for efficient bulk processing - Built-in statistics and monitoring **Current Backends:** - **SQLite** - File-based or in-memory storage (default) - **pgvector** - PostgreSQL with pgvector extension (production-ready) - **Qdrant** - Cloud-native vector database via REST API - **Elasticsearch** - `dense_vector` kNN search in a cluster you already run, in the separate module `llm4s-vectorstore-elasticsearch` (Experimental); see [Elasticsearch](#elasticsearch) - **OpenSearch** - `knn_vector` search with the k-NN plugin, in the same module (Experimental); see [OpenSearch](#opensearch) - **Redis and Valkey** - KNN search over a vector index in the Redis (Redis Stack, Redis 8+) or Valkey (`valkey-search`) you already run, in the separate module `llm4s-vectorstore-redis` (Experimental); see [Redis and Valkey](#redis-and-valkey) **Keyword Backends (for hybrid search):** - **SQLite FTS5** - File-based or in-memory BM25 search (default) - **PostgreSQL** - Native full-text search with tsvector/tsquery (production-ready) **Planned Backends:** - Milvus - Pinecone --- ## Quick Start ### In-Memory Store ```scala import org.llm4s.vectorstore._ // Create an in-memory store val store = VectorStoreFactory.inMemory().fold( e => throw new RuntimeException(s"Failed: ${e.formatted}"), identity ) // Store a vector val record = VectorRecord( id = "doc-1", embedding = Array(0.1f, 0.2f, 0.3f), content = Some("Hello world"), metadata = Map("type" -> "greeting") ) store.upsert(record) // Search for similar vectors val results = store.search( queryVector = Array(0.1f, 0.2f, 0.3f), topK = 5 ) // A Result: Right(hits), or Left(error) results.foreach(_.foreach { scored => println(s"${scored.record.id}: ${scored.score}") }) // Clean up store.close() ``` ### File-Based Store ```scala import org.llm4s.vectorstore._ // Create a persistent store val store = VectorStoreFactory.sqlite("/path/to/vectors.db").fold( e => throw new RuntimeException(s"Failed: ${e.formatted}"), identity ) // Use the store... store.close() ``` ### PostgreSQL with pgvector ```scala import org.llm4s.vectorstore._ // Local PostgreSQL with defaults val store = VectorStoreFactory.pgvector().fold( e => throw new RuntimeException(s"Failed: ${e.formatted}"), identity ) // Or with explicit connection settings (a Result, like every factory method) val store2 = VectorStoreFactory.pgvector( connectionString = "jdbc:postgresql://localhost:5432/mydb", user = "postgres", password = "secret", tableName = "embeddings" ) // Create HNSW index for faster search (optional) store.asInstanceOf[PgVectorStore].createHnswIndex() store.close() ``` **Setup:** Requires PostgreSQL with pgvector extension: ```sql CREATE EXTENSION IF NOT EXISTS vector; ``` ### Sharing your application's connection pool The factories above create a HikariCP pool and close it with the store. An application that already has a pool - a doobie `HikariTransactor`, a Play or Slick pool, or a `javax.sql.DataSource` a platform team hands out with credential rotation, metrics and TLS configured - can pass it instead. Every Postgres store and index takes a plain `javax.sql.DataSource` that it **does not own**: closing the store leaves the pool open, and the pool's owner closes it. | Store | Over a caller-owned `DataSource` | | ----- | -------------------------------- | | `PgVectorStore` | `PgVectorStore.create(dataSource, tableName)` or `PgVectorStore(dataSource, tableName)` | | `PgKeywordIndex` | `PgKeywordIndex(dataSource, tableName)` or `PgKeywordIndex(dataSource, tableName, language)` | | `HybridSearcher` | `HybridSearcher.pgvectorShared(dataSource, vectorTableName, keywordTableName)` (optionally a `FusionStrategy`) | | `PgSearchIndex` | `PgSearchIndex.fromDataSource(dataSource, vectorTableName)` | | `PostgresMemoryStore` | `PostgresMemoryStore.fromDataSource(dataSource, tableName, embeddingService)` | With doobie, the transactor's `kernel` is the `HikariDataSource` behind it. Build the store inside the transactor's `Resource`, so the pool outlives every store that borrows it: ```scala import cats.effect.{ IO, Resource } import doobie.hikari.HikariTransactor import doobie.util.ExecutionContexts import org.llm4s.vectorstore.{ PgVectorStore, VectorRecord } // The application's pool. Its Resource closes it - no LLM4S store does. val transactor: Resource[IO, HikariTransactor[IO]] = for { connectEc <- ExecutionContexts.fixedThreadPool[IO](16) xa <- HikariTransactor.newHikariTransactor[IO]( "org.postgresql.Driver", "jdbc:postgresql://localhost:5432/mydb", "postgres", "secret", connectEc ) } yield xa // The store borrows `xa.kernel`; closing the store leaves the pool open. val vectorStore: Resource[IO, PgVectorStore] = transactor.flatMap { xa => Resource.make( IO.blocking(PgVectorStore.create(xa.kernel, "embeddings")) .flatMap(result => IO.fromEither(result.left.map(e => new RuntimeException(e.formatted)))) )(store => IO.blocking(store.close())) } // Store calls are blocking JDBC: run them on the blocking pool. val program: IO[Long] = vectorStore.use { store => IO.blocking { for { _ <- store.upsert(VectorRecord("doc-1", Array(0.1f, 0.2f, 0.3f), Some("Scala programming"))) count <- store.count() } yield count }.flatMap(result => IO.fromEither(result.left.map(e => new RuntimeException(e.formatted)))) } ``` Every store call is blocking JDBC. From cats-effect code run it with `IO.blocking` (or `Sync[F].blocking`), from ZIO with `ZIO.attemptBlocking`, so it never occupies a compute thread. Any `DataSource` works, not only HikariCP's: pgjdbc's `PGSimpleDataSource` opens a connection per call, which is fine for tests and scripts. Hikari stays an implementation detail of the config-based factories, and no public signature names it. ### Qdrant ```scala import org.llm4s.vectorstore._ // Local Qdrant (docker run -p 6333:6333 qdrant/qdrant) val store = VectorStoreFactory.qdrant().fold( e => throw new RuntimeException(s"Failed: ${e.formatted}"), identity ) // Or Qdrant Cloud val cloudStore = VectorStoreFactory.qdrantCloud( cloudUrl = "https://your-cluster.qdrant.io", apiKey = "your-api-key", collectionName = "my_vectors" ) store.close() ``` ### Elasticsearch `ElasticsearchVectorStore` keeps the vectors in one index of an Elasticsearch cluster, through the REST API and `llm4s-core`'s HTTP client: no Elasticsearch client library is added. It lives in its own module (Experimental): ```scala libraryDependencies += "org.llm4s" %% "llm4s-vectorstore-elasticsearch" % llm4sVersion ``` ```scala import org.llm4s.vectorstore.elasticsearch._ // Local single node with security off: // docker run -p 9200:9200 -e discovery.type=single-node -e xpack.security.enabled=false \ // docker.elastic.co/elasticsearch/elasticsearch:8.15.3 val local = ElasticsearchVectorStore.local(index = "my-vectors") // A secured cluster: an API key (its `encoded` value) or a username and password val encodedKey = "your-encoded-api-key" // from your secret store val config = ElasticsearchConfig("https://es.example.com:9200") .withIndex("my-vectors") .withAuth(ElasticsearchAuth.ApiKey(encodedKey)) .withSimilarity(ElasticsearchSimilarity.Cosine) // or DotProduct, L2Norm, MaxInnerProduct .withDimensions(1536) // unset: the first record's size val store = ElasticsearchVectorStore(config) ``` Or from configuration, at the app edge: ```hocon llm4s.vectorstore.elasticsearch { baseUrl = "https://es.example.com:9200" # ELASTICSEARCH_URL; default http://localhost:9200 index = "my-vectors" # ELASTICSEARCH_INDEX; default llm4s-vectors apiKey = ${?ELASTICSEARCH_API_KEY} # or username / password (ELASTICSEARCH_USERNAME, _PASSWORD) similarity = cosine # cosine | dot_product | l2_norm | max_inner_product dimensions = 1536 # numCandidates = 100 # kNN candidates per shard; default max(100, 10 * topK) # refreshOnWrite = true # writes wait for a refresh, so a search sees them at once # timeout = 30 seconds } ``` ```scala import org.llm4s.config.ElasticsearchVectorStoreConfigLoader val store = for { config <- ElasticsearchVectorStoreConfigLoader.default() store <- ElasticsearchVectorStore(config) } yield store ``` How it maps onto Elasticsearch: - **Index**: created on the first upsert when it does not exist, with a `dense_vector` field (`embedding`, indexed for kNN with the configured similarity and size), `content` as `text`, `id` as `keyword`, and every metadata value as a `keyword` under `metadata` (a dynamic template). `clear()` deletes the index; the next upsert creates it again. Reads of an index that does not exist yet answer as an empty store. - **Writes**: `upsert` and `upsertBatch` are one `_bulk` request, and the document `_id` is the record ID, so an upsert replaces. A record whose size differs from the configured `dimensions` is refused before anything is sent; a record Elasticsearch rejects is reported with its ID and reason. `deleteByPrefix` and `deleteByFilter` are `_delete_by_query` and return the number deleted. - **Search**: an approximate kNN search with the metadata filter inside the `knn` clause, so `topK` results come back when that many records match. Elasticsearch's `_score` is clamped to `[0, 1]`: it already lies there for `cosine` and `l2_norm` (and for `dot_product` over unit vectors), while `max_inner_product` can score above 1. - **Filters**: `Equals` is a `term` query, `In` a `terms` query, `HasKey` an `exists` query, `Contains` a `wildcard` query (`*substring*`, matched literally), and `And`, `Or` and `Not` are `bool` queries. - **Errors**: a 401 or 403 is an `AuthenticationError`, a 429 a `RateLimitError`, any other failing status a `ServiceError` with the status and the redacted body; configured credentials are masked wherever a body echoes them. Limits to know: `list` pages with `from` and `size`, so `offset + limit` cannot exceed the index's `max_result_window` (10,000 by default); `Contains` is a leading-wildcard query, which a cluster with `search.allow_expensive_queries: false` refuses; a metadata key containing a dot is mapped as a nested object path, so keys `a` and `a.b` cannot both be used. The base URL must be `https` when credentials are configured, unless the host is loopback. `ElasticsearchVectorStoreSpec` in `modules/it` (`@Docker` tier, `ELASTICSEARCH_TEST_URL`) runs every operation against a real cluster. ### OpenSearch `OpenSearchVectorStore` keeps the vectors in one index of an OpenSearch cluster with the k-NN plugin (bundled with the standard distribution), through the REST API: no OpenSearch client library is added. It is in the same module as the Elasticsearch store, `llm4s-vectorstore-elasticsearch` (Experimental), and shares its code: OpenSearch kept Elasticsearch 7.10's document, `_bulk` and query DSL APIs, so records, writes, reads, deletes, `stats` and every metadata filter behave as described for [Elasticsearch](#elasticsearch). Only the vector field, the kNN query, the scores and the authentication differ, and those are what `OpenSearchVectorStore` changes. ```scala import org.llm4s.vectorstore.opensearch._ // Local single node with the security plugin off: // docker run -p 9200:9200 -e discovery.type=single-node -e DISABLE_SECURITY_PLUGIN=true \ // -e DISABLE_INSTALL_DEMO_CONFIG=true opensearchproject/opensearch:2.19.1 val local = OpenSearchVectorStore.local(index = "my-vectors") // A secured cluster: a username and password, or a bearer token (JWT) val password = "your-password" // from your secret store val config = OpenSearchConfig("https://search.example.com:9200") .withIndex("my-vectors") .withAuth(OpenSearchAuth.Basic("llm4s", password)) .withSpaceType(OpenSearchSpaceType.CosineSimil) // or InnerProduct, L2 .withEngine(OpenSearchEngine.Lucene) // or Faiss, Nmslib (deprecated) .withDimensions(1536) // unset: the first record's size val store = OpenSearchVectorStore(config) ``` Or from configuration, at the app edge: ```hocon llm4s.vectorstore.opensearch { baseUrl = "https://search.example.com:9200" # OPENSEARCH_URL; default http://localhost:9200 index = "my-vectors" # OPENSEARCH_INDEX; default llm4s-vectors username = llm4s # OPENSEARCH_USERNAME password = ${?OPENSEARCH_PASSWORD} # or token = ${?OPENSEARCH_TOKEN}, not both spaceType = cosinesimil # cosinesimil | innerproduct | l2 engine = lucene # lucene | faiss | nmslib dimensions = 1536 # m = 16 # HNSW links per node; default: the engine's # efConstruction = 100 # default: the engine's # numCandidates = 100 # the query's k; see below # refreshOnWrite = true # timeout = 30 seconds } ``` ```scala import org.llm4s.config.OpenSearchVectorStoreConfigLoader val store = for { config <- OpenSearchVectorStoreConfigLoader.default() store <- OpenSearchVectorStore(config) } yield store ``` How it maps onto OpenSearch: - **Index**: created on the first upsert with `index.knn: true` and a `knn_vector` field (`embedding`) of the configured `dimension`, indexed with `method` `hnsw`, the configured `space_type` and `engine`, and `m` and `ef_construction` when set. The other fields are mapped as for Elasticsearch. - **Search**: a `knn` query, `{"knn": {"embedding": {"vector": [...], "k": k}}}`. On Lucene (OpenSearch 2.4+) and Faiss (2.9+) the metadata filter goes inside it as `filter` - efficient filtering, so `topK` results come back when that many records match, and `k` defaults to `topK`. NMSLIB has no efficient filtering, so there the `knn` query is the `must` clause of a `bool` query whose `filter` clause drops non-matching neighbours afterwards; `k` then defaults to `max(100, 10 * topK)`, and a selective filter can still return fewer than `topK` - raise `numCandidates` (the `k` sent) if it does. `k` is never below `topK` and at most 10,000. - **Scores**: OpenSearch scores each space differently; the store maps them onto `[0, 1]` like Elasticsearch's. `cosinesimil` is already `(1 + cosine) / 2` on every engine and is kept; `innerproduct`'s `dot + 1` (or `1 / (1 - dot)` for a negative dot) becomes `(1 + dot) / 2`, which lies in `[0, 1]` for unit vectors and is clamped otherwise; `l2`'s `1 / (1 + distance²)` is kept. - **Authentication**: HTTP basic (the security plugin's internal users, also on Amazon OpenSearch Service with fine-grained access control) or `Authorization: Bearer` (its JWT backend). AWS Signature Version 4 (IAM) request signing is not supported; use an internal user, or a signing proxy in front of the domain. - **Errors**: as for Elasticsearch, with `opensearch` as the provider of the error. Engine notes: Faiss supports `cosinesimil` from OpenSearch 2.19 and normalises the vectors it indexes; NMSLIB is deprecated by OpenSearch, and a newer cluster may refuse to create an NMSLIB index, so use it only for an index that already has it. The `knn_vector` dimension limit is 16,000. `OpenSearchVectorStoreSpec` in `modules/it` (`@Docker` tier, `OPENSEARCH_TEST_URL`) runs every operation against a real cluster, on each engine. ### Redis and Valkey `RedisVectorStore` keeps the vectors as hashes in a Redis server with the Redis Query Engine (Redis Stack, or Redis 8 and later, where search is built in) or a Valkey server with the `valkey-search` module (the `valkey-bundle` image), and searches them through a vector index. Redis speaks RESP rather than HTTP, so the module brings a client library, [Jedis](https://github.com/redis/jedis) (MIT); it stays out of the store's API. It lives in its own module (Experimental): ```scala libraryDependencies += "org.llm4s" %% "llm4s-vectorstore-redis" % llm4sVersion ``` ```scala import org.llm4s.vectorstore.redis._ // Local server without authentication: // docker run -p 6379:6379 redis/redis-stack-server:7.4.0-v8 (or valkey/valkey-bundle:9.0.6) val local = RedisVectorStore.local(index = "my-vectors") // A remote server: TLS, an ACL user and a password val password = "your-password" // from your secret store val config = RedisConfig("redis.example.com", port = 6380) .withTls(true) .withUsername("app") .withPassword(password) .withIndex("my-vectors") .withDistanceMetric(RedisDistanceMetric.Cosine) // or InnerProduct, L2 .withAlgorithm(RedisVectorAlgorithm.Hnsw) // or Flat .withDimensions(1536) // unset: the first record's size val store = RedisVectorStore(config) ``` Or from configuration, at the app edge: ```hocon llm4s.vectorstore.redis { host = "redis.example.com" # REDIS_HOST; default localhost port = 6380 # REDIS_PORT; default 6379 tls = true # REDIS_TLS; default false username = ${?REDIS_USERNAME} # unset: the default user password = ${?REDIS_PASSWORD} # unset: no authentication index = "my-vectors" # REDIS_VECTOR_INDEX; default llm4s-vectors distanceMetric = COSINE # COSINE | IP | L2 algorithm = HNSW # HNSW | FLAT dimensions = 1536 # keyPrefix = "my-vectors:" # default ":" # database = 0 # timeout = 10 seconds } ``` ```scala import org.llm4s.config.RedisVectorStoreConfigLoader val store = for { config <- RedisVectorStoreConfigLoader.default() store <- RedisVectorStore(config) } yield store ``` How it maps onto Redis: - **Records**: each record is a hash at ``, holding the ID, the content, the metadata as a JSON object, the vector as little-endian `FLOAT32` bytes, a write sequence number, and the metadata again as encoded tags. `upsert` and `upsertBatch` are one pipeline of `DEL` and `HSET` per record, so an upsert replaces the whole record. A record whose size differs from the index's is refused before anything is sent. - **Index**: created on the first upsert when it does not exist (`FT.CREATE ... ON HASH PREFIX 1 `), with the `VECTOR` field (`embedding`: the configured algorithm, distance and size), a sortable `NUMERIC` field for write order, and two `TAG` fields for metadata. An index that exists is used as it is. Reads of an index that does not exist yet answer as an empty store. `clear()` drops the index and deletes the store's keys; the next upsert creates it again. - **Search**: a KNN query, `()=>[KNN k @embedding $BLOB]`, so the filter is a pre-filter and `topK` results come back when that many records match. Redis reports a distance; the score is `1 - distance / 2` for `COSINE` and `IP` (that is `(1 + cosine) / 2`) and `1 / (1 + distance)` for `L2`, clamped to `[0, 1]`. - **Filters**: any metadata value can be filtered on; they are all strings. The schema of a search index is fixed and metadata keys are not, so every entry is one tag of a `TAG` field, encoded (hex bytes) so that no value can clash with the tag separator, case folding, whitespace trimming or query escaping. `Equals` and `In` match entries, `HasKey` matches keys, `Contains` is a wildcard query (`w'...'`), and `And`, `Or` and `Not` combine clauses. An empty `In` matches nothing and is answered without a query. - **Deletes**: `delete` and `deleteBatch` are one `DEL`. `deleteByPrefix` walks the store's keys with `SCAN`; `deleteByFilter` deletes the matching records a page at a time. Both return the number deleted. - **Errors**: a connection failure is a `NetworkError` (a `TimeoutError` when the server does not answer in time), refused credentials (`NOAUTH`, `WRONGPASS`, `NOPERM`) an `AuthenticationError`, and any other error reply a `ProcessingError` with the server's message. Valkey: the `valkey-search` in Valkey 9.0 (`valkey-bundle:9.0`) answers only vector queries. On it `upsert`, `get`, `getBatch`, `delete`, `deleteBatch`, `deleteByPrefix`, `clear` and `search` with every filter but `Contains` work (the index is created without `SORTABLE`, which it refuses), while `count` and `stats` over an index, `list` and `deleteByFilter` are refused with the server's message, as is `Contains`, which has no wildcard query there. Limits to know: `Contains` needs the Redis Query Engine 2.6 or later. A wildcard expands to at most `search-max-prefix-expansions` distinct values (200 by default), so `Contains` over a key with more distinct values than that may miss records. A search reply holds at most `search-max-search-results` records (10,000 by default), which bounds `topK` and `list`'s `offset + limit`. The store connects to one standalone server, not a cluster, and the Redis Query Engine indexes only database 0 on most deployments. `deleteByPrefix` and `clear()` scan the keyspace. Without `tls` the password crosses the network in clear text: keep that to a trusted network. `RedisVectorStoreSpec` and `ValkeyVectorStoreSpec` in `modules/it` (`@Docker` tier, `REDIS_TEST_URL` and `VALKEY_TEST_URL`) run every operation against real servers. --- ## Core Concepts ### VectorRecord A `VectorRecord` represents a single entry in the vector store: ```scala final case class VectorRecord( id: String, // Unique identifier embedding: Array[Float], // Vector embedding content: Option[String] = None, // Optional text content metadata: Map[String, String] = Map.empty // Key-value metadata ) ``` **Creating Records:** ```scala // With explicit ID val record1 = VectorRecord( id = "doc-123", embedding = Array(0.1f, 0.2f, 0.3f), content = Some("Document text"), metadata = Map("source" -> "wiki", "lang" -> "en") ) // With auto-generated ID val record2 = VectorRecord.create( embedding = Array(0.1f, 0.2f, 0.3f), content = Some("Another document") ) // Add metadata fluently val record3 = VectorRecord("id", Array(1.0f)) .withMetadata("key1", "value1") .withMetadata(Map("key2" -> "value2", "key3" -> "value3")) ``` ### ScoredRecord Search results include similarity scores: ```scala final case class ScoredRecord( record: VectorRecord, score: Double // 0.0 to 1.0, higher is more similar ) ``` --- ## Operations ### CRUD Operations The fragments from here to the end of the page use `store`, a `VectorStore` such as the one in the Quick Start, `record` and `records` (a `VectorRecord` and a `Seq[VectorRecord]`), `ids` (a `Seq[String]`) and `embeddingVector`, a query embedding (an `Array[Float]`). ```scala // Single record operations store.upsert(record) // Insert or replace store.get("doc-id") // Retrieve by ID store.delete("doc-id") // Delete by ID // Batch operations (more efficient) store.upsertBatch(records) // Insert/replace multiple store.getBatch(ids) // Retrieve multiple store.deleteBatch(ids) // Delete multiple // Clear all records store.clear() ``` ### Search ```scala // Basic search val results = store.search( queryVector = embeddingVector, topK = 10 ) // Search with metadata filter val filter = MetadataFilter.Equals("type", "document") val filtered = store.search( queryVector = embeddingVector, topK = 10, filter = Some(filter) ) ``` ### Listing and Pagination ```scala // List all records val all = store.list() // Paginate results val page1 = store.list(limit = 10, offset = 0) val page2 = store.list(limit = 10, offset = 10) // List with filter val docs = store.list(filter = Some(MetadataFilter.Equals("type", "doc"))) ``` ### Statistics ```scala val stats = store.stats() stats.foreach { s => println(s"Total records: ${s.totalRecords}") println(s"Dimensions: ${s.dimensions}") println(s"Size: ${s.formattedSize}") } ``` --- ## Metadata Filtering The `MetadataFilter` DSL allows composing complex filters: ### Basic Filters ```scala import org.llm4s.vectorstore.MetadataFilter._ // Exact match val byType = Equals("type", "document") // Contains substring val byContent = Contains("summary", "Scala") // Has key (any value) val hasAuthor = HasKey("author") // Value in set val byLang = In("lang", Set("en", "es", "fr")) ``` ### Combining Filters ```scala // AND - both conditions must match val andFilter = Equals("type", "doc").and(Equals("lang", "en")) // OR - either condition can match val orFilter = Equals("type", "doc").or(Equals("type", "article")) // NOT - negate a filter val notFilter = !Equals("archived", "true") // Complex combinations val complex = Equals("type", "doc") .and(Equals("lang", "en").or(Equals("lang", "es"))) .and(!Equals("draft", "true")) ``` ### Using Filters With `byType` and `complex` from the filters above: ```scala // In search store.search(embeddingVector, topK = 10, filter = Some(byType)) // In list store.list(filter = Some(complex)) // In count store.count(filter = Some(byType)) // Delete by filter store.deleteByFilter(Equals("archived", "true")) ``` --- ## Factory Configuration ### Using VectorStoreFactory `VectorStoreFactory` covers the backends inside `llm4s-rag` (SQLite, pgvector, Qdrant). A backend in a module of its own, such as [Elasticsearch](#elasticsearch), [OpenSearch](#opensearch) or [Redis](#redis-and-valkey), is built with its own constructor, and any `VectorStore` can be passed wherever one is taken. ```scala import org.llm4s.vectorstore._ // In-memory (default) val memStore = VectorStoreFactory.inMemory() // File-based SQLite val fileStore = VectorStoreFactory.sqlite("/path/to/db.sqlite") // From provider name val store = VectorStoreFactory.create("sqlite", path = Some("/path/to/db.sqlite")) // From config object val config = VectorStoreFactory.Config.sqlite("/path/to/db.sqlite") val configStore = VectorStoreFactory.create(config) ``` ### Configuration Options ```scala // Default in-memory config val defaultConfig = VectorStoreFactory.Config.default // SQLite file config val sqliteConfig = VectorStoreFactory.Config.sqlite("/path/to/vectors.db") // In-memory config val memConfig = VectorStoreFactory.Config.inMemory // pgvector config val pgConfig = VectorStoreFactory.Config.pgvector( connectionString = "jdbc:postgresql://localhost:5432/postgres", tableName = "vectors" ) // Qdrant config val qdrantConfig = VectorStoreFactory.Config.qdrant( collectionName = "vectors", port = 6333 ) // Qdrant Cloud config val qdrantCloudConfig = VectorStoreFactory.Config.qdrantCloud( cloudUrl = "https://xxx.qdrant.io", apiKey = "your-api-key", collectionName = "vectors" ) // With options val withOptions = VectorStoreFactory.Config() .withSQLite("/path/to/db.sqlite") .withOption("cache_size", "10000") ``` --- ## Hybrid Search Hybrid search combines vector similarity (semantic) with BM25 keyword matching for better retrieval quality. LLM4S provides a `HybridSearcher` that fuses results from both search types. ### Quick Start ```scala import org.llm4s.vectorstore._ val embedding = Array(0.1f, 0.2f, 0.3f) val results = for { // Create stores vectorStore <- VectorStoreFactory.inMemory() keywordIndex <- KeywordIndex.inMemory() // Create hybrid searcher searcher = HybridSearcher(vectorStore, keywordIndex) // Index documents in both stores _ <- vectorStore.upsert(VectorRecord("doc-1", embedding, Some("Scala programming language"))) _ <- keywordIndex.index(KeywordDocument("doc-1", "Scala programming language")) // Search with both vector and keyword found <- searcher.search( queryEmbedding = embedding, queryText = "Scala", topK = 10 ) _ = searcher.close() } yield found results.foreach(_.foreach { r => println(s"${r.id}: ${r.score} (vector: ${r.vectorScore}, keyword: ${r.keywordScore})") }) ``` ### Fusion Strategies Choose how to combine vector and keyword scores, given a `searcher: HybridSearcher` and a query `embedding` as in the Quick Start: ```scala import org.llm4s.vectorstore.FusionStrategy._ // Reciprocal Rank Fusion (default) - rank-based, robust to score differences val rrfResults = searcher.search(embedding, "query", strategy = RRF(k = 60)) // Weighted Score - normalized scores with configurable weights val weightedResults = searcher.search( embedding, "query", strategy = WeightedScore(vectorWeight = 0.7, keywordWeight = 0.3) ) // Single-mode search val vectorOnly = searcher.search(embedding, "query", strategy = VectorOnly) val keywordOnly = searcher.search(embedding, "query", strategy = KeywordOnly) ``` **When to use each strategy:** | Strategy | Best For | |----------|----------| | RRF (default) | General use, heterogeneous sources | | WeightedScore | When you know relative importance | | VectorOnly | Semantic/conceptual queries | | KeywordOnly | Exact term matching, names, codes | #### How WeightedScore combines the two channels Vector similarity and BM25 scores live on different scales, so each channel is first rescaled on its own: its best hit becomes `1.0`, its weakest hit becomes `0.1`, and everything between is spread linearly. A chunk the channel did not return at all scores `0.0` for that channel. The final score is the weighted average of the two channel scores, so it lies in `[0, 1]`. If a channel returned a single hit, or all its hits tie, each of them scores `1.0`. The `0.1` floor is deliberate. With plain min-max the weakest hit would map to `0.0`, the same as a miss, so a relevant chunk that happened to rank last in one channel would contribute nothing from it. ```scala import org.llm4s.vectorstore._ // Only the vector channel counts (vector weight 1.0), and the keyword channel finds nothing. val hits = for { store <- VectorStoreFactory.inMemory() keywords <- KeywordIndex.inMemory() _ <- store.upsert(VectorRecord("strong", Array(1f, 0f), Some("strong"))) _ <- store.upsert(VectorRecord("weak", Array(0.6f, 0.8f), Some("weak"))) found <- HybridSearcher(store, keywords).search( Array(1f, 0f), "no keyword matches this", topK = 5, strategy = FusionStrategy.WeightedScore(vectorWeight = 1.0, keywordWeight = 0.0) ) } yield found.map(hit => hit.id -> hit.score) // Right(Seq("strong" -> 1.0, "weak" -> 0.1)): the weak hit is not 0.0 ``` #### Invalid weights `WeightedScore` needs finite, non-negative weights that are not both zero and whose sum is finite. Anything else (`WeightedScore(0.0, 0.0)`, a negative, `NaN` or infinite weight) throws `IllegalArgumentException` from the constructor, as does `RAGConfig.withWeightedScore`, which builds one. That is deliberate: weights written in code are a programming error, like any `require`-guarded case class, and the chainable `RAGConfig` builders return a `RAGConfig`, so they cannot return a `Left`. If the weights come from user input, check them first so a bad value becomes a `Left` instead of an exception. Letting the constructor do the checking keeps the rules in one place: ```scala import org.llm4s.types.{ Result, TryOps } import org.llm4s.vectorstore.FusionStrategy import scala.util.Try def weightedScore(vector: Double, keyword: Double): Result[FusionStrategy] = Try(FusionStrategy.WeightedScore(vector, keyword)).toResult ``` ### BM25 Keyword Index The `KeywordIndex` provides BM25-scored full-text search using SQLite FTS5: ```scala import org.llm4s.vectorstore._ val results = for { // Create keyword index index <- KeywordIndex.inMemory() // Index documents _ <- index.index(KeywordDocument("doc-1", "Scala is a programming language")) _ <- index.index(KeywordDocument("doc-2", "Python is also popular")) // Search with highlights found <- index.searchWithHighlights("programming", topK = 5) _ = index.close() } yield found results.foreach(_.foreach { r => println(s"${r.id}: ${r.score}") r.highlights.foreach(h => println(s" ...${h}...")) }) ``` ### Factory Methods Each returns a `Result[HybridSearcher]`: ```scala import org.llm4s.vectorstore._ // In-memory (development) val memSearcher = HybridSearcher.inMemory() // File-based SQLite val fileSearcher = HybridSearcher.sqlite( vectorDbPath = "/path/to/vectors.db", keywordDbPath = "/path/to/keywords.db" ) // From configuration val config = HybridSearcher.Config() .withVectorStore( VectorStoreFactory.Config.pgvector( connectionString = "jdbc:postgresql://localhost:5432/mydb", tableName = "vectors" ) ) .withRRF(k = 60) val configSearcher = HybridSearcher(config) ``` ### PostgreSQL Native Hybrid Search For production deployments, you can run fully PostgreSQL-based hybrid search using pgvector for vector similarity and PostgreSQL native full-text search (tsvector/tsquery) for BM25-like keyword matching. This eliminates the need for a separate SQLite database. **Requirements:** PostgreSQL 16+ with pgvector extension (PostgreSQL 18+ recommended for best performance) ```scala import org.llm4s.vectorstore._ val embedding = Array(0.1f, 0.2f, 0.3f) val results = for { // Create fully PostgreSQL-based hybrid searcher with shared connection pool searcher <- HybridSearcher.pgvectorShared( connectionString = "jdbc:postgresql://localhost:5432/mydb", user = "postgres", password = "secret", vectorTableName = "vectors", keywordTableName = "documents" ) // Index documents (both vector and keyword stores share the same PostgreSQL database) _ <- searcher.vectorStore.upsert(VectorRecord("doc-1", embedding, Some("Scala programming"))) _ <- searcher.keywordIndex.index(KeywordDocument("doc-1", "Scala programming")) // Search using both vector similarity and PostgreSQL full-text search found <- searcher.search(embedding, "Scala", topK = 10) _ = searcher.close() } yield found ``` **PostgreSQL Keyword Index:** The `PgKeywordIndex` uses PostgreSQL native full-text search capabilities: - **tsvector** for tokenized document representation - **ts_rank_cd** for BM25-like relevance scoring - **websearch_to_tsquery** for natural search syntax (supports `"exact phrases"`, `OR`, `-exclude`) - **ts_headline** for highlighted snippets - **GIN indexes** for fast full-text and metadata queries ```scala import org.llm4s.vectorstore._ val filtered = for { // Create standalone PostgreSQL keyword index keywordIndex <- KeywordIndex.postgres( connectionString = "jdbc:postgresql://localhost:5432/mydb", user = "postgres", password = "secret", tableName = "documents" // Creates documents_keyword table ) // Index with metadata _ <- keywordIndex.index(KeywordDocument( id = "doc-1", content = "PostgreSQL provides powerful full-text search", metadata = Map("lang" -> "en", "type" -> "tutorial") )) // Search with highlights highlighted <- keywordIndex.searchWithHighlights("full-text search", topK = 5) _ = highlighted.foreach { r => println(s"${r.id}: ${r.score}") r.highlights.foreach(h => println(s" ${h}")) // Contains highlighted terms } // Filter by metadata found <- keywordIndex.search( "PostgreSQL", topK = 10, filter = Some(MetadataFilter.Equals("lang", "en")) ) _ = keywordIndex.close() } yield found ``` **RAG Configuration:** Use `withPgHybrid()` to configure fully PostgreSQL-based hybrid search in the RAG pipeline: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.model.ModelRegistryService import org.llm4s.rag._ val config = RAGConfig() .withEmbeddings("openai") .withPgHybrid( connectionString = "jdbc:postgresql://localhost:5432/mydb", user = "postgres", password = "secret", vectorTableName = "vectors", keywordTableName = "documents" ) .withRRF(60) // Reciprocal Rank Fusion val results = for { registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry // The embedding provider's settings come from llm4s.embeddings rag <- RAG.build(config, _ => Llm4sConfig.embeddings().map(_._2)) // Documents are stored in PostgreSQL for both vector and keyword search _ <- rag.ingestText("PostgreSQL hybrid search combines vector and full-text.", "doc-1") found <- rag.query("database search") } yield found ``` **Benefits of PostgreSQL Native Hybrid:** - Single database for all RAG storage (vectors, keywords, metadata) - Shared connection pool for efficiency - Native PostgreSQL transactions and ACID guarantees - Simplified deployment and backup strategy - Full-text search with stemming, stop words, and language support --- ## Reranking Reranking improves retrieval quality by re-scoring initial search results using a more powerful model (like Cohere's cross-encoder). This is particularly useful when you retrieve many candidates and want to refine the ranking. ### Quick Start ```scala import org.llm4s.vectorstore._ import org.llm4s.reranker._ val embedding = Array(0.1f, 0.2f, 0.3f) // the query's embedding val reranker = RerankerFactory.cohere(apiKey = "your-cohere-api-key") val results = for { // Create hybrid searcher (index documents into it as in Hybrid Search) searcher <- HybridSearcher.inMemory() // Search with reranking found <- searcher.searchWithReranking( queryEmbedding = embedding, queryText = "What is Scala?", topK = 5, // Final results to return rerankTopK = 50, // Candidates to rerank reranker = Some(reranker) ) } yield found results.foreach(_.foreach { r => println(s"${r.id}: ${r.score}") }) ``` ### Reranker Options ```scala import org.llm4s.reranker._ // Cohere reranker (recommended for production) val cohereReranker = RerankerFactory.cohere( apiKey = "your-api-key", model = "rerank-english-v3.0", // or rerank-multilingual-v3.0 baseUrl = "https://api.cohere.com" ) // Passthrough reranker (no-op, preserves original order) val passthrough = RerankerFactory.passthrough // From typed config val config = RerankProviderConfig( apiKey = "your-api-key", model = "rerank-english-v3.0", baseUrl = "https://api.cohere.com" ) val fromConfig = RerankerFactory.fromConfig(Some(config)) ``` ### LLM Reranker `LLMReranker` uses any configured chat model as the judge, so you can rerank without a Cohere key. Build it directly or through `RerankerFactory.llm`: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService import org.llm4s.reranker.{ LLMReranker, RerankerFactory } val reranker = for { registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry providerCfg <- Llm4sConfig.defaultProvider() client <- LLMConnect.getClient(providerCfg) } yield LLMReranker(client) // same as RerankerFactory.llm(client) // Optional knobs (both have defaults): // RerankerFactory.llm(client, batchSize = 5, systemPrompt = Some("...")) ``` How it scores: - **Batches.** Documents go to the model `batchSize` at a time (default `LLMReranker.DEFAULT_BATCH_SIZE` = 10), one model call per batch. A smaller batch means more calls but shorter prompts. - **1,000-unit cut.** Each document is truncated to its first 1,000 UTF-16 code units (the Scala `String` length), stopping one short rather than splitting a surrogate pair, before it goes into the prompt. That is 1,000 characters for most text, but emoji-heavy text can be cut after roughly 500 characters. Put the most relevant text first in long chunks. - **Scores.** The model must answer with a JSON array of scores. A number, or a numeric string such as `"0.9"`, is clamped to `[0, 1]`; `null`, booleans and other non-string values score 0.5. A string that is not a number fails the parse for the whole batch, so the entire batch gets neutral scores: `[0.9, "unknown"]` gives both documents 0.5, not 0.9 and 0.5. A short array is padded with 0.5; a long one is cut to the batch size. - **Failed batches.** If a batch's call or parse fails, every document in that batch gets a neutral 0.5 and reranking continues with the next batch. Cancellation is the exception: it is returned as an error. - **`systemPrompt`.** Replaces the default judging instructions. Keep asking for "ONLY a JSON array of scores" so replies stay parseable. **LLM vs Cohere:** the LLM reranker reuses the model you already have and needs no extra service, but every batch is a full chat completion. That makes it slower and usually more expensive per query than Cohere's dedicated rerank endpoint, which scores all candidates in one request. Prefer Cohere for latency-sensitive or high-volume retrieval, and the LLM reranker when you can't add another provider or want the judging criteria in your own prompt. ### Reranking in the RAG pipeline `RAGConfig` selects the strategy with `RerankingStrategy`: | Strategy | Builder | Notes | |---|---|---| | `RerankingStrategy.None` | (default) | Results keep their fused retrieval order | | `RerankingStrategy.Cohere(model)` | `.withCohereReranking(model = "rerank-english-v3.0")` | Needs a Cohere reranker config. The model comes from that `RerankProviderConfig`; the `model` argument is currently not used to choose it | | `RerankingStrategy.LLM` | `.withLLMReranking` | Needs an LLM client: add `.withLLM(client)` when building RAG | `.withRerankTopK(k)` sets how many retrieved candidates are reranked. ### Direct Reranking API ```scala import org.llm4s.reranker._ val reranker = RerankerFactory.cohere(apiKey = "xxx") val request = RerankRequest( query = "What is Scala?", documents = Seq( "Scala is a programming language", "Python is popular for ML", "Scala runs on the JVM" ), topK = Some(2) ) val response = reranker.rerank(request) response.foreach { r => r.results.foreach { result => println(s"[${result.index}] ${result.score}: ${result.document}") } } ``` ### Configuration Load the reranker from configuration at the edge of your application with `RerankerConfigLoader` (in `llm4s-rag`), and hand it to `RAG.build`: ```scala import org.llm4s.config.{ Llm4sConfig, RerankerConfigLoader } import org.llm4s.model.ModelRegistryService import org.llm4s.rag.{ RAG, RAGConfig } val config = RAGConfig().withEmbeddings("openai") for { registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry reranker <- RerankerConfigLoader.default() // Option[RerankProviderConfig]; None when disabled rag <- RAG.build( config, _ => Llm4sConfig.embeddings().map(_._2), resolveRerankerConfig = () => Right(reranker) ) } yield rag ``` It reads `llm4s.rerank`. The Cohere key is `llm4s.rerank.cohere.apiKey` when set, and otherwise Cohere's shared `llm4s.credentials.cohere.apiKey`, bound to `COHERE_API_KEY` - the key a Cohere chat section uses too, so one variable serves both. Set `llm4s.rerank.cohere.apiKey` only to bill reranking to another account. (Before `RerankerConfigLoader`, nothing read `llm4s.rerank`, although `reference.conf` bound it.) | Environment Variable | Description | Default | |---------------------|-------------|---------| | `RERANK_PROVIDER` | Provider: cohere, none | none | | `COHERE_API_KEY` | Cohere API key, shared with Cohere chat (`llm4s.credentials.cohere.apiKey`) | - | | `COHERE_RERANK_MODEL` | Model name | rerank-english-v3.0 | | `COHERE_RERANK_BASE_URL` | API base URL | https://api.cohere.com | --- ## Document Chunking Document chunking splits text into manageable pieces for embedding and retrieval. LLM4S provides multiple chunking strategies optimized for different content types. In the examples, `documentText` is the text to split. ### Quick Start ```scala import org.llm4s.chunking._ // Create a sentence-aware chunker (recommended) val chunker = ChunkerFactory.sentence() // Chunk a document val chunks = chunker.chunk(documentText, ChunkingConfig( targetSize = 800, // Target chunk size in characters maxSize = 1200, // Longer sentences are split at whitespace; a single longer word stays whole overlap = 150 // Overlap between consecutive chunks )) chunks.foreach { chunk => println(s"[${chunk.index}] ${chunk.content.take(50)}...") } ``` ### Chunking Strategies ```scala import org.llm4s.chunking._ // Sentence-aware chunking (recommended for most text) // Respects sentence boundaries for semantic coherence val sentenceChunker = ChunkerFactory.sentence() // Simple character-based chunking // Fast but may split mid-sentence val simpleChunker = ChunkerFactory.simple() // Auto-detect based on content // Detects markdown and chooses appropriate strategy val autoChunker = ChunkerFactory.auto(documentText) // By strategy name val chunker = ChunkerFactory.create("sentence") // or "simple" ``` ### Configuration Presets ```scala import org.llm4s.chunking._ // Default: 800 char target, 150 overlap val defaultConfig = ChunkingConfig.default // Small chunks: 400 char target, 75 overlap // Better for precise retrieval val smallConfig = ChunkingConfig.small // Large chunks: 1500 char target, 250 overlap // Better for broader context val largeConfig = ChunkingConfig.large // No overlap val noOverlapConfig = ChunkingConfig.noOverlap // Custom configuration val customConfig = ChunkingConfig( targetSize = 600, maxSize = 900, overlap = 100, minChunkSize = 50, preserveCodeBlocks = true, preserveHeadings = true ) ``` ### With Source Metadata ```scala import org.llm4s.chunking._ val chunker = ChunkerFactory.sentence() // Chunks include source file in metadata val chunks = chunker.chunkWithSource( text = documentText, sourceFile = "docs/guide.md", config = ChunkingConfig.default ) chunks.foreach { chunk => println(s"From: ${chunk.metadata.sourceFile.getOrElse("unknown")}") println(s"Content: ${chunk.content.take(50)}...") } ``` ### Invalid Chunking Settings `ChunkingConfig` checks its values when you build it, so a chunker never receives a configuration it cannot handle: | Rule | Failing example | |------|-----------------| | `targetSize` is positive | `ChunkingConfig(targetSize = 0)` | | `maxSize >= targetSize` | `ChunkingConfig(targetSize = 800, maxSize = 400)` | | `0 <= overlap < targetSize` | `ChunkingConfig(targetSize = 800, overlap = 800)` | | `minChunkSize` is not negative | `ChunkingConfig(minChunkSize = -1)` | A value that breaks a rule throws `IllegalArgumentException` (the message names the rule, for example `overlap must be >= 0 and < targetSize`). `RAGConfig.withChunking(strategy, size, overlap)` builds a `ChunkingConfig`, so it throws too. Every configuration that *can* be built is safe: no chunker throws for it, and the largest overlap allowed (`targetSize - 1`) still moves each chunk one character forward. When the sizes come from user input, check them first. To get a `Left(ValidationError)` straight from the numbers, use `ChunkingUtils.chunkTextValidated`; to build a config, let the constructor decide: ```scala import org.llm4s.chunking.ChunkingConfig import org.llm4s.llmconnect.utils.ChunkingUtils import org.llm4s.types.{ Result, TryOps } import scala.util.Try val text = "one two three four five six seven eight nine ten" // Left(ValidationError) with field "overlap" (or "size"), not an exception val chunks = ChunkingUtils.chunkTextValidated(text, size = 20, overlap = 20) def chunkingConfig(size: Int, overlap: Int): Result[ChunkingConfig] = Try( ChunkingConfig( targetSize = size, maxSize = math.min(size.toLong * 3 / 2, Int.MaxValue.toLong).toInt, // Long: no overflow for a large size overlap = overlap ) ).toResult ``` ### Chunking Best Practices | Content Type | Recommended Strategy | Config | |--------------|---------------------|--------| | Prose/articles | `sentence` | `default` | | Technical docs | `sentence` | `large` | | Code files | `simple` | Custom with no overlap | | Q&A pairs | `sentence` | `small` | | Mixed content | `auto` | `default` | **Tips:** - Use overlap for context continuity in retrieval - Smaller chunks improve retrieval precision but may lose context - Larger chunks preserve context but may dilute relevance - Sentence chunking prevents mid-thought splits --- ## Integration with RAG Pipeline ### Complete RAG Example ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.{ EmbeddingClient, LLMConnect } import org.llm4s.llmconnect.config.{ EmbeddingModelConfig, ModelDimensionRegistry } import org.llm4s.llmconnect.model.{ Completion, Conversation, EmbeddingRequest, InputPurpose, UserMessage } import org.llm4s.model.ModelRegistryService import org.llm4s.types.Result import org.llm4s.vectorstore._ val documents = Seq( "Scala is a programming language", "LLM4S provides LLM integration", "Vector stores enable semantic search" ) val query = "What is Scala?" val response: Result[Completion] = for { registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry // 1. Create embedding client and vector store settings <- Llm4sConfig.embeddings() providerId = settings._1 embeddingConfig = settings._2 dimensions <- ModelDimensionRegistry.getDimension(providerId, embeddingConfig.model) model = EmbeddingModelConfig(embeddingConfig.model, dimensions) embeddingClient <- EmbeddingClient.from(providerId, embeddingConfig) vectorStore <- VectorStoreFactory.inMemory() // 2. Ingest documents: one request, one vector per document, in order embedded <- embeddingClient.embed(EmbeddingRequest(documents, model)) records = documents.zip(embedded.embeddings).zipWithIndex.map { case ((doc, vector), idx) => VectorRecord(id = s"doc-$idx", embedding = vector.map(_.toFloat).toArray, content = Some(doc)) } _ <- vectorStore.upsertBatch(records) // 3. Query with retrieval queried <- embeddingClient.embed(EmbeddingRequest(Seq(query), model, InputPurpose.Query)) relevant <- vectorStore.search(queried.embeddings.flatten.map(_.toFloat).toArray, topK = 3) // 4. Augment prompt with context context = relevant.map(_.record.content.getOrElse("")).mkString("\n") prompt = s"""Based on the following context: $context Answer this question: $query""" // 5. Generate response providerConfig <- Llm4sConfig.defaultProvider() llm <- LLMConnect.getClient(providerConfig) completion <- llm.complete(Conversation(Seq(UserMessage(prompt)))) } yield completion ``` --- ## Best Practices ### Resource Management Always close stores when done: ```scala import org.llm4s.vectorstore._ val count = VectorStoreFactory.inMemory().flatMap { store => // Use scala.util.Using for automatic cleanup scala.util.Using.resource(new java.io.Closeable { def close(): Unit = store.close() }) { _ => store.count() // Use the store } } ``` ### Batch Operations Use batch operations for efficiency: ```scala // Good - single batch call store.upsertBatch(records) // Less efficient - individual calls records.foreach(store.upsert) ``` ### Error Handling All operations return `Result[A]`: ```scala store.search(embeddingVector, topK = 10) match { case Right(results) => results.foreach(r => println(r.score)) case Left(error) => println(s"Search failed: ${error.formatted}") } // Or use for-comprehension for { results <- store.search(embeddingVector, topK = 10) count <- store.count() } yield (results, count) ``` ### Metadata Design Design metadata for your filtering needs: ```scala // Good - filterable metadata VectorRecord( id = "doc-1", embedding = embeddingVector, metadata = Map( "type" -> "article", "source" -> "wikipedia", "lang" -> "en", "year" -> "2024" ) ) // Then filter efficiently store.search( embeddingVector, topK = 10, filter = Some(Equals("type", "article").and(Equals("lang", "en"))) ) ``` --- ## Performance Considerations ### SQLite Backend The SQLite backend is suitable for: - Development and testing - Small to medium datasets (~100K vectors) - Single-machine deployments - Scenarios where simplicity is preferred **Limitations:** - Vector similarity computed in Scala (not hardware-accelerated) - All candidate vectors loaded into memory during search - No built-in sharding or replication ### pgvector Backend PostgreSQL with pgvector is ideal for: - Production workloads with existing PostgreSQL infrastructure - Medium to large datasets (millions of vectors) - Teams familiar with PostgreSQL operations - Applications requiring ACID transactions **Features:** - HNSW indexing for fast approximate nearest neighbor search - Connection pooling with HikariCP - Native vector operations in PostgreSQL - Excellent SQL tooling and monitoring **Setup:** ```bash # Enable pgvector extension psql -c "CREATE EXTENSION IF NOT EXISTS vector;" ``` ### Qdrant Backend Qdrant is recommended for: - High-performance production workloads - Large-scale deployments (billions of vectors) - Cloud-native architectures - Teams wanting managed vector database service **Features:** - Cloud-native architecture with horizontal scaling - REST and gRPC APIs - Rich filtering on payload fields - Snapshot and backup capabilities - Managed cloud offering available **Setup:** ```bash # Local development with Docker docker run -p 6333:6333 qdrant/qdrant # Or use Qdrant Cloud for production ``` ### Choosing a Backend | Requirement | Recommended Backend | |-------------|---------------------| | Development/testing | SQLite (in-memory) | | Single-machine production | SQLite (file-based) | | Existing PostgreSQL | pgvector | | Existing Elasticsearch or OpenSearch cluster | `ElasticsearchVectorStore` / `OpenSearchVectorStore` | | Full PostgreSQL hybrid RAG | pgvector + PgKeywordIndex | | High-scale production | Qdrant | | Managed service | Qdrant Cloud | **Note:** For hybrid search with PostgreSQL, use `HybridSearcher.pgvectorShared()` or `RAGConfig.withPgHybrid()` to run both vector and keyword search against a single PostgreSQL database. This provides simplified operations, shared connection pooling, and ACID guarantees. --- ## API Reference ### VectorStore Trait ```scala trait VectorStore { def upsert(record: VectorRecord): Result[Unit] def upsertBatch(records: Seq[VectorRecord]): Result[Unit] def search(queryVector: Array[Float], topK: Int = 10, filter: Option[MetadataFilter] = None): Result[Seq[ScoredRecord]] def get(id: String): Result[Option[VectorRecord]] def getBatch(ids: Seq[String]): Result[Seq[VectorRecord]] def delete(id: String): Result[Unit] def deleteBatch(ids: Seq[String]): Result[Unit] def deleteByFilter(filter: MetadataFilter): Result[Long] def count(filter: Option[MetadataFilter] = None): Result[Long] def list(limit: Int = 100, offset: Int = 0, filter: Option[MetadataFilter] = None): Result[Seq[VectorRecord]] def clear(): Result[Unit] def stats(): Result[VectorStoreStats] def close(): Unit } ``` --- ## Measuring RAG Quality Once you have a RAG pipeline, use the evaluation framework to measure and improve retrieval quality. ### Quick Evaluation Given an `LLMClient` to judge with (`client`), an `EmbeddingClient` (`embedder`) and its `EmbeddingModelConfig` (`embeddingModel`): ```scala import org.llm4s.rag.evaluation._ val evaluator = new RAGASEvaluator(client, embedder, embeddingModel) val sample = EvalSample( question = "What is Scala?", answer = "Scala is a programming language that runs on the JVM.", // your pipeline's answer contexts = Seq("Scala is a programming language", "Scala runs on the JVM"), // the chunks it retrieved groundTruth = Some("Scala is a programming language.") ) val metrics = evaluator.evaluate(sample) metrics.foreach { m => println(s"RAGAS Score: ${m.ragasScore}") m.getMetric("faithfulness").foreach(f => println(s"Faithfulness: ${f.score}")) } ``` ### Benchmarking Different Configurations Compare chunking strategies, fusion methods, and embedding providers: ```bash # Compare fusion strategies sbt "samples/runMain org.llm4s.samples.rag.BenchmarkExample --suite fusion" # Compare chunking strategies sbt "samples/runMain org.llm4s.samples.rag.BenchmarkExample --suite chunking" ``` **See the full [RAG Evaluation Guide](rag-evaluation) for:** - RAGAS metrics explained (faithfulness, relevancy, precision, recall) - Running benchmarks programmatically - Optimization workflow and best practices - Actual benchmark results and recommendations --- ## Next Steps - **[RAG Evaluation Guide](rag-evaluation)** - Measure and improve RAG quality - **[Embeddings Configuration](../getting-started/configuration#embeddings-configuration)** - Configure embedding providers - **[Examples Gallery](/examples/#embeddings-examples)** - See RAG examples in action - **[RAG in a Box](https://github.com/llm4s/rag_in_a_box)** - Production-ready RAG server with REST API, admin UI, and deployment options --- Source: https://llm4s.org/guide/permission-based-rag # Permission-Based RAG Enterprise-grade access control for RAG applications. --- ## Overview Permission-based RAG extends LLM4S with enterprise-grade access control for document retrieval. It enables you to: - **Organize documents in collections** with hierarchical structure (e.g., `confluence/EN/archive`) - **Control who can search** with collection-level `queryableBy` permissions - **Fine-grained document access** with document-level `readableBy` permissions - **Map users and groups** to efficient integer IDs for fast database queries - **Query with patterns** like `*` (all), `confluence/*` (immediate children), `confluence/**` (all descendants) **Key Features:** - Two-level permission model (collection + document) - Permission inheritance (children can only restrict, never loosen) - Pattern-based collection queries - PostgreSQL backend with pgvector for efficient vector search - Seamless integration with the existing RAG API --- ## Quick Start ### Prerequisites You need PostgreSQL with pgvector extension: ```bash # Using Docker docker run -d --name pgvector -p 5432:5432 \ -e POSTGRES_PASSWORD=postgres \ pgvector/pgvector:pg16 # Or enable on existing PostgreSQL psql -c "CREATE EXTENSION IF NOT EXISTS vector;" ``` ### Minimal Example (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.model.ModelRegistryService import org.llm4s.rag.RAG import org.llm4s.rag.RAG.RAGConfigOps import org.llm4s.rag.permissions._ import org.llm4s.rag.permissions.pg.PgSearchIndex // build needs the extension import above and a model registry; ProviderRegistry.default is a given. given ModelRegistryService = Llm4sConfig.modelRegistryService().toOption.get // 1. Create and initialize the SearchIndex val searchIndex = PgSearchIndex.fromJdbcUrl( jdbcUrl = "jdbc:postgresql://localhost:5432/postgres", user = "postgres", password = "postgres", vectorTableName = "rag_vectors" ).getOrElse(throw new RuntimeException("Failed to create SearchIndex")) searchIndex.initializeSchema().getOrElse(throw new RuntimeException("Failed to init schema")) // 2. Create a collection val collectionPath = CollectionPath.unsafe("my-docs") searchIndex.collections.create(CollectionConfig.publicLeaf(collectionPath)) // 3. Build RAG with permission support val rag = RAG.builder() .withEmbeddings("openai") .withSearchIndex(searchIndex) .build .getOrElse(throw new RuntimeException("Failed to build RAG")) // 4. Ingest a document rag.ingestWithPermissions( collectionPath = collectionPath, documentId = "doc-1", content = "LLM4S is a framework for building LLM-powered applications in Scala.", metadata = Map("source" -> "readme") ) // 5. Query with permissions val results = rag.queryWithPermissions( auth = UserAuthorization.Admin, // Admin bypasses all checks collectionPattern = CollectionPattern.All, queryText = "What is LLM4S?" ) results.foreach { r => r.foreach(result => println(s"${result.id}: ${result.content.take(50)}...")) } // 6. Cleanup rag.close() searchIndex.close() ``` --- ## Core Concepts ### Principal IDs Principals represent users and groups. They're mapped to integers for efficient database queries: (not compile-checked) ```scala import org.llm4s.rag.permissions._ // Users have positive IDs val userId = PrincipalId.user(42) // Creates PrincipalId(42) userId.isUser // true userId.isGroup // false // Groups have negative IDs val groupId = PrincipalId.group(5) // Creates PrincipalId(-5) groupId.isUser // false groupId.isGroup // true // Create from raw value PrincipalId.fromRaw(42) // Right(PrincipalId(42)) PrincipalId.fromRaw(-5) // Right(PrincipalId(-5)) PrincipalId.fromRaw(0) // Left(error) - zero not allowed ``` ### External Principals External principals are human-readable identifiers that get mapped to integer IDs: (not compile-checked) ```scala import org.llm4s.rag.permissions._ // User by email val user = ExternalPrincipal.User("john@example.com") user.externalId // "user:john@example.com" // Group by name val group = ExternalPrincipal.Group("engineering") group.externalId // "group:engineering" // Parse from string ExternalPrincipal.parse("user:john@example.com") // Right(User("john@example.com")) ExternalPrincipal.parse("group:admins") // Right(Group("admins")) ExternalPrincipal.parse("invalid") // Left(error) ``` ### Collection Paths Collections use hierarchical paths with forward-slash separators: (not compile-checked) ```scala import org.llm4s.rag.permissions._ // Create with validation val path = CollectionPath.create("confluence/EN/archive") // Right(CollectionPath(Seq("confluence", "EN", "archive"))) // Path properties path.foreach { p => p.value // "confluence/EN/archive" p.depth // 3 p.name // "archive" (final segment) p.isRoot // false p.parent // Some(CollectionPath("confluence/EN")) } // Hierarchy relationships val parent = CollectionPath.unsafe("confluence") val child = CollectionPath.unsafe("confluence/EN") val grandchild = CollectionPath.unsafe("confluence/EN/archive") child.isChildOf(parent) // true grandchild.isChildOf(parent) // false (not direct child) grandchild.isDescendantOf(parent) // true // Invalid paths are rejected CollectionPath.create("path with spaces") // Left(error) CollectionPath.create("") // Left(error) ``` ### Collection Patterns Patterns filter which collections to search: (not compile-checked) ```scala import org.llm4s.rag.permissions._ // Match all collections CollectionPattern.All // Match exact path CollectionPattern.Exact(CollectionPath.unsafe("confluence/EN")) // Match immediate children only CollectionPattern.ImmediateChildren(CollectionPath.unsafe("confluence")) // Matches: confluence/EN, confluence/DE // Does NOT match: confluence/EN/archive // Match all descendants CollectionPattern.AllDescendants(CollectionPath.unsafe("confluence")) // Matches: confluence, confluence/EN, confluence/EN/archive, confluence/DE // Parse from string CollectionPattern.parse("*") // All CollectionPattern.parse("confluence") // Exact CollectionPattern.parse("confluence/*") // ImmediateChildren CollectionPattern.parse("confluence/**") // AllDescendants ``` ### User Authorization Authorization context contains the user's principal IDs: (not compile-checked) ```scala import org.llm4s.rag.permissions._ // Create for a user with group memberships val auth = UserAuthorization.forUser( userId = PrincipalId.user(42), groups = Set(PrincipalId.group(1), PrincipalId.group(2)) ) auth.principalIds // Set(PrincipalId(42), PrincipalId(-1), PrincipalId(-2)) auth.isAdmin // false // Admin bypasses all permission checks val adminAuth = UserAuthorization.Admin adminAuth.isAdmin // true // Anonymous user (no permissions) val anonAuth = UserAuthorization.Anonymous anonAuth.principalIds.isEmpty // true // Check if user includes a specific principal auth.includes(PrincipalId.user(42)) // true auth.includes(PrincipalId.group(1)) // true auth.includes(PrincipalId.user(99)) // false ``` --- ## Collections ### Creating Collections Collections are created through the `CollectionStore`: (not compile-checked) ```scala import org.llm4s.rag.permissions._ val collections = searchIndex.collections // Public leaf collection (anyone can query, can contain documents) val publicConfig = CollectionConfig.publicLeaf( CollectionPath.unsafe("public-docs") ) collections.create(publicConfig) // Restricted collection (only specific groups can query) val restrictedConfig = CollectionConfig.restrictedLeaf( path = CollectionPath.unsafe("internal"), queryableBy = Set(PrincipalId.group(1)) // Only engineering group ) collections.create(restrictedConfig) // Parent collection (can have sub-collections, not documents) val parentConfig = CollectionConfig.publicParent( CollectionPath.unsafe("confluence") ) collections.create(parentConfig) // Fluent configuration val config = CollectionConfig(CollectionPath.unsafe("my-collection")) .withQueryableBy(PrincipalId.group(5)) .withQueryableBy(Set(PrincipalId.user(1), PrincipalId.user(2))) .withMetadata("description", "My documents") .asLeaf ``` ### Hierarchical Structure Collections form a tree structure: ``` SearchIndex ├── confluence (parent, queryableBy: [employees]) │ ├── confluence/EN (leaf, inherits + can restrict) │ ├── confluence/DE (leaf, inherits) │ └── confluence/archive (leaf, inherits) ├── internal (parent, queryableBy: [admins]) │ └── internal/hr (leaf, queryableBy: [hr-team]) └── public (leaf, queryableBy: []) ← empty = public ``` **Rules:** - Parent collections cannot contain documents (only sub-collections) - Leaf collections can contain documents - When you create a child, the parent automatically becomes a non-leaf - Permissions are inherited: children can only restrict, never loosen (not compile-checked) ```scala // Create parent first collections.create(CollectionConfig.publicParent(CollectionPath.unsafe("confluence"))) // Create child - parent automatically becomes non-leaf collections.create(CollectionConfig.publicLeaf(CollectionPath.unsafe("confluence/EN"))) // Or use ensureExists to create parent hierarchy automatically collections.ensureExists(CollectionConfig.publicLeaf( CollectionPath.unsafe("deep/nested/collection") )) // Creates: deep (parent), deep/nested (parent), deep/nested/collection (leaf) ``` ### Permission Inheritance Permissions flow down the hierarchy: (not compile-checked) ```scala val principals = searchIndex.principals // Create groups val employees = principals.getOrCreate(ExternalPrincipal.Group("employees")).toOption.get val managers = principals.getOrCreate(ExternalPrincipal.Group("managers")).toOption.get // Parent restricted to employees collections.create(CollectionConfig( path = CollectionPath.unsafe("company"), queryableBy = Set(employees), isLeaf = false )) // Child further restricted to managers only collections.create(CollectionConfig( path = CollectionPath.unsafe("company/executive"), queryableBy = Set(managers), isLeaf = true )) // Get effective permissions (considers inheritance) collections.getEffectivePermissions(CollectionPath.unsafe("company/executive")) // Returns intersection: only managers who are also employees // Cannot loosen permissions collections.create(CollectionConfig( path = CollectionPath.unsafe("company/public"), queryableBy = Set.empty, // Trying to make public isLeaf = true )) // ERROR: Cannot make collection public when parent is restricted ``` ### Querying Collections (not compile-checked) ```scala val collections = searchIndex.collections // Get a specific collection collections.get(CollectionPath.unsafe("confluence/EN")) // Right(Some(Collection(...))) // List all collections matching a pattern collections.list(CollectionPattern.AllDescendants(CollectionPath.unsafe("confluence"))) // Seq(confluence, confluence/EN, confluence/DE, ...) // Find accessible collections for a user val userAuth = UserAuthorization.forUser(userId, groups = Set(employeesGroup)) collections.findAccessible(userAuth, CollectionPattern.All) // Only returns collections the user can access // List children of a collection collections.listChildren(CollectionPath.unsafe("confluence")) // Seq(confluence/EN, confluence/DE, ...) // Check if user can query a collection collections.canQuery(CollectionPath.unsafe("internal"), userAuth) // Right(true) or Right(false) // Get collection statistics collections.stats(CollectionPath.unsafe("confluence/EN")) // Right(CollectionStats(documentCount = 42, chunkCount = 156, subCollectionCount = 0)) ``` --- ## Documents ### Ingesting with Permissions Use `ingestWithPermissions` to add documents with access control: (not compile-checked) ```scala // Public document (inherits collection permissions) rag.ingestWithPermissions( collectionPath = CollectionPath.unsafe("public-docs"), documentId = "doc-1", content = "This document is visible to anyone who can query the collection.", metadata = Map("source" -> "wiki", "author" -> "john") ) // Restricted document (only specific users can read) rag.ingestWithPermissions( collectionPath = CollectionPath.unsafe("public-docs"), documentId = "confidential-doc", content = "This is confidential information.", metadata = Map("classification" -> "confidential"), readableBy = Set(PrincipalId.user(42), PrincipalId.group(1)) ) ``` ### Two-Level Permission Model Permissions are checked at two levels: 1. **Collection-level (`queryableBy`)**: Controls who can search the collection 2. **Document-level (`readableBy`)**: Fine-grained access within a collection ``` Collection: "hr-docs" (queryableBy: [hr-group]) ├── doc-1 (readableBy: []) ← Anyone in hr-group can read ├── doc-2 (readableBy: [hr-managers]) ← Only HR managers └── doc-3 (readableBy: [ceo]) ← Only CEO can read ``` **Query Flow:** 1. Filter collections by user's principals (`queryableBy` check) 2. Vector search within accessible collections 3. Filter results by `readableBy` check 4. Return permission-filtered results ### Deleting Documents (not compile-checked) ```scala // Delete a specific document rag.deleteFromCollection( collectionPath = CollectionPath.unsafe("public-docs"), documentId = "doc-1" ) // Clear all documents in a collection (via SearchIndex) searchIndex.clearCollection(CollectionPath.unsafe("public-docs")) ``` --- ## Querying ### Permission-Filtered Search (not compile-checked) ```scala // Query with user permissions val userAuth = UserAuthorization.forUser( userId = PrincipalId.user(42), groups = Set(PrincipalId.group(1)) ) val results = rag.queryWithPermissions( auth = userAuth, collectionPattern = CollectionPattern.All, queryText = "What is the vacation policy?", topK = Some(10) ) results.foreach { searchResults => searchResults.foreach { result => println(s"[${result.score}] ${result.id}") println(s" Content: ${result.content.take(100)}...") println(s" Metadata: ${result.metadata}") } } ``` ### Query with Answer Generation (not compile-checked) ```scala // Requires LLM client to be configured val rag = RAG.builder() .withEmbeddings("openai") .withSearchIndex(searchIndex) .withLLM(llmClient) // Add LLM for answer generation .build .getOrElse(???) val answer = rag.queryWithPermissionsAndAnswer( auth = userAuth, collectionPattern = CollectionPattern.AllDescendants(CollectionPath.unsafe("hr")), question = "How many vacation days do employees get?", topK = Some(5) ) answer.foreach { result => println(s"Answer: ${result.answer}") println(s"Based on ${result.contexts.size} sources") } ``` ### Collection Patterns in Queries (not compile-checked) ```scala // Search all collections rag.queryWithPermissions(auth, CollectionPattern.All, "query") // Search exact collection rag.queryWithPermissions( auth, CollectionPattern.Exact(CollectionPath.unsafe("hr/policies")), "query" ) // Search immediate children of a collection rag.queryWithPermissions( auth, CollectionPattern.ImmediateChildren(CollectionPath.unsafe("confluence")), "query" ) // Searches: confluence/EN, confluence/DE, but NOT confluence/EN/archive // Search all descendants rag.queryWithPermissions( auth, CollectionPattern.AllDescendants(CollectionPath.unsafe("confluence")), "query" ) // Searches: confluence, confluence/EN, confluence/EN/archive, etc. ``` --- ## Principal Management ### Creating and Looking Up Principals (not compile-checked) ```scala val principals = searchIndex.principals // Create or get existing principal val john = principals.getOrCreate(ExternalPrincipal.User("john@example.com")) val engineering = principals.getOrCreate(ExternalPrincipal.Group("engineering")) // Batch create val mapping = principals.getOrCreateBatch(Seq( ExternalPrincipal.User("alice@example.com"), ExternalPrincipal.User("bob@example.com"), ExternalPrincipal.Group("hr") )) // Lookup without creating principals.lookup(ExternalPrincipal.User("john@example.com")) // Right(Some(PrincipalId(42))) principals.lookup(ExternalPrincipal.User("unknown@example.com")) // Right(None) // Reverse lookup principals.getExternalId(PrincipalId(42)) // Right(Some(ExternalPrincipal.User("john@example.com"))) // List all users principals.list("user", limit = 100, offset = 0) // Count groups principals.count("group") ``` --- ## Schema Management ### Initializing the Schema The schema must be initialized before use: (not compile-checked) ```scala val searchIndex = PgSearchIndex.fromJdbcUrl( jdbcUrl = "jdbc:postgresql://localhost:5432/postgres", user = "postgres", password = "postgres", vectorTableName = "rag_vectors" ).getOrElse(???) // Initialize permission tables and indexes searchIndex.initializeSchema() ``` This creates: - `llm4s_principals` table for user/group ID mapping - `llm4s_collections` table for collection hierarchy - Extends your vectors table with `collection_id` and `readable_by` columns - GIN indexes for efficient array containment queries ### Migration from Non-Permission RAG If you have existing vectors without permissions: (not compile-checked) ```scala import org.llm4s.rag.permissions.pg.PgSchemaManager import java.sql.DriverManager val conn = DriverManager.getConnection(jdbcUrl, user, password) // Run full migration PgSchemaManager.runFullMigration(conn, "vectors").foreach { stats => println(s"Created ${stats.tablesCreated} tables") println(s"Created ${stats.indexesCreated} indexes") println(s"Migrated ${stats.vectorsMigrated} vectors to default collection") } conn.close() ``` This: 1. Creates permission tables 2. Adds `collection_id` and `readable_by` columns to your vectors table 3. Creates a "default" public collection 4. Migrates existing vectors to the default collection --- ## Complete Example Here's a complete example showing a multi-tenant document system: (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.model.ModelRegistryService import org.llm4s.rag.RAG import org.llm4s.rag.RAG.RAGConfigOps import org.llm4s.rag.permissions._ import org.llm4s.rag.permissions.pg.PgSearchIndex object MultiTenantRAGExample extends App { given ModelRegistryService = Llm4sConfig.modelRegistryService().toOption.get // 1. Setup val searchIndex = PgSearchIndex.fromJdbcUrl( "jdbc:postgresql://localhost:5432/postgres", "postgres", "postgres", "vectors" ).getOrElse(throw new RuntimeException("Failed to create SearchIndex")) searchIndex.initializeSchema() val principals = searchIndex.principals val collections = searchIndex.collections // 2. Create users and groups val john = principals.getOrCreate(ExternalPrincipal.User("john@acme.com")).toOption.get val alice = principals.getOrCreate(ExternalPrincipal.User("alice@acme.com")).toOption.get val engineering = principals.getOrCreate(ExternalPrincipal.Group("engineering")).toOption.get val hr = principals.getOrCreate(ExternalPrincipal.Group("hr")).toOption.get // 3. Create collection hierarchy // acme (parent, all employees) // ├── acme/engineering (engineering group) // │ └── acme/engineering/secrets (restricted) // └── acme/hr (hr group) collections.create(CollectionConfig( path = CollectionPath.unsafe("acme"), queryableBy = Set(engineering, hr), // All employees isLeaf = false )) collections.create(CollectionConfig.restrictedLeaf( CollectionPath.unsafe("acme/engineering"), Set(engineering) )) collections.create(CollectionConfig.restrictedLeaf( CollectionPath.unsafe("acme/hr"), Set(hr) )) // 4. Build RAG val rag = RAG.builder() .withEmbeddings("openai") .withSearchIndex(searchIndex) .build .getOrElse(throw new RuntimeException("Failed to build RAG")) // 5. Ingest documents rag.ingestWithPermissions( CollectionPath.unsafe("acme/engineering"), "api-docs", "Our REST API uses OAuth 2.0 for authentication...", metadata = Map("type" -> "documentation") ) rag.ingestWithPermissions( CollectionPath.unsafe("acme/hr"), "vacation-policy", "Employees receive 20 days of paid vacation per year...", metadata = Map("type" -> "policy") ) // Secret document only Alice can read rag.ingestWithPermissions( CollectionPath.unsafe("acme/hr"), "salary-data", "Confidential salary information...", readableBy = Set(alice) // Only Alice can read ) // 6. Query as different users val johnAuth = UserAuthorization.forUser(john, Set(engineering)) val aliceAuth = UserAuthorization.forUser(alice, Set(hr)) println("=== John (Engineering) searching 'vacation' ===") rag.queryWithPermissions(johnAuth, CollectionPattern.All, "vacation policy").foreach { results => if (results.isEmpty) println("No results (no access to HR docs)") else results.foreach(r => println(s"Found: ${r.id}")) } println("\n=== Alice (HR) searching 'vacation' ===") rag.queryWithPermissions(aliceAuth, CollectionPattern.All, "vacation policy").foreach { results => results.foreach(r => println(s"Found: ${r.id}")) } println("\n=== Alice searching 'salary' ===") rag.queryWithPermissions(aliceAuth, CollectionPattern.All, "salary").foreach { results => results.foreach(r => println(s"Found: ${r.id} (secret doc visible to Alice)")) } // 7. Cleanup rag.close() searchIndex.close() } ``` --- ## Best Practices ### Collection Design 1. **Use meaningful hierarchy**: `{org}/{department}/{project}` or `{source}/{category}` 2. **Keep paths short**: Deep nesting increases permission check overhead 3. **Plan for growth**: Use parent collections for organizational units 4. **Document your structure**: Create a diagram of your collection hierarchy ### Permission Design 1. **Prefer groups over users**: Easier to manage at scale 2. **Use empty `queryableBy` for public**: More efficient than listing all users 3. **Reserve `readableBy` for exceptions**: Most docs should inherit from collection 4. **Validate early**: Check permissions before expensive operations ### Performance 1. **Use batch operations**: `getOrCreateBatch` for multiple principals 2. **Create indexes**: The schema manager creates GIN indexes automatically 3. **Limit pattern scope**: Use specific patterns instead of `All` when possible 4. **Monitor collection sizes**: Large collections may need partitioning --- ## API Reference ### SearchIndex Trait (not compile-checked) ```scala trait SearchIndex { def principals: PrincipalStore def collections: CollectionStore def query( auth: UserAuthorization, collectionPattern: CollectionPattern, queryVector: Array[Float], topK: Int = 10, additionalFilter: Option[MetadataFilter] = None ): Result[Seq[ScoredRecord]] def ingest( collectionPath: CollectionPath, documentId: String, chunks: Seq[ChunkWithEmbedding], metadata: Map[String, String] = Map.empty, readableBy: Set[PrincipalId] = Set.empty ): Result[Int] def deleteDocument(collectionPath: CollectionPath, documentId: String): Result[Long] def clearCollection(collectionPath: CollectionPath): Result[Long] def stats(collectionPath: CollectionPath): Result[CollectionStats] def initializeSchema(): Result[Unit] def close(): Unit } ``` ### RAG Permission Methods (not compile-checked) ```scala class RAG { def searchIndex: Option[SearchIndex] def hasPermissions: Boolean def queryWithPermissions( auth: UserAuthorization, collectionPattern: CollectionPattern, queryText: String, topK: Option[Int] = None ): Result[Seq[RAGSearchResult]] def queryWithPermissionsAndAnswer( auth: UserAuthorization, collectionPattern: CollectionPattern, question: String, topK: Option[Int] = None ): Result[RAGAnswerResult] def ingestWithPermissions( collectionPath: CollectionPath, documentId: String, content: String, metadata: Map[String, String] = Map.empty, readableBy: Set[PrincipalId] = Set.empty ): Result[Int] def deleteFromCollection( collectionPath: CollectionPath, documentId: String ): Result[Long] } ``` --- ## Troubleshooting ### Common Issues **"Collection not found" error** - Ensure the collection exists before ingesting - Use `ensureExists` to create parent hierarchy automatically **"Cannot make collection public when parent is restricted"** - Child collections cannot have looser permissions than parents - Either make the parent public or restrict the child to a subset of parent's principals **"SearchIndex required" error** - Call `.withSearchIndex(index)` when building RAG - Ensure the SearchIndex is created and initialized **Empty search results** - Check that the user has access to the collection (`canQuery`) - Verify documents have matching `readableBy` permissions - Confirm the collection pattern includes the target collection ### Debugging Tips (not compile-checked) ```scala // Check user's accessible collections collections.findAccessible(userAuth, CollectionPattern.All).foreach { accessible => println(s"User can access: ${accessible.map(_.path.value)}") } // Check effective permissions collections.getEffectivePermissions(path).foreach { perms => if (perms.isEmpty) println("Collection is public") else println(s"Required principals: ${perms.map(_.value)}") } // Verify collection exists and is a leaf collections.get(path).foreach { case Some(c) => println(s"Collection ${c.path.value}: isLeaf=${c.isLeaf}") case None => println("Collection not found") } ``` --- ## Next Steps - **[Vector Store Guide](vector-store)** - Learn about the underlying vector storage - **[RAG Evaluation Guide](rag-evaluation)** - Measure and improve RAG quality - **[Examples Gallery](/examples/#embeddings-examples)** - See more RAG examples --- Source: https://llm4s.org/guide/error-handling # Error Handling with Result How to work with `Result[A]` and `LLMError` in practice. --- LLM4S does not throw exceptions for failures it expects: a missing API key, a rate limit, a timeout and a rejected request all come back as values. If you know Java or Python, this replaces `try`/`catch` with something the compiler checks. This page shows how to use it day to day. Every snippet after section 1 (which only shows definitions) is compiled and run by [`ErrorHandlingGuideSpec`](https://github.com/llm4s/llm4s/blob/main/modules/core/src/test/scala/org/llm4s/error/ErrorHandlingGuideSpec.scala), so the names and calls in it match the current API, and each snippet shows the imports it needs. If you change one, change the other. ## 1. What is `Result[A]`? ```scala type Result[+A] = Either[LLMError, A] ``` A call either succeeds with a `Right(value)` or fails with a `Left(error)`, where `error` is an `LLMError`. There is no third outcome: nothing is thrown past you, and a `null` is never returned in place of an error. Two imports come up. The type alias is in `org.llm4s.types`; the helper object (`Result.success`, `Result.traverse` and friends, used in [section 8](#8-combining-results)) is `org.llm4s.Result`: ```scala import org.llm4s.Result // the helper object import org.llm4s.types.{ Result, TryOps } // the type alias, and Try-to-Result syntax ``` ## 2. The basic pattern Match on the result. `Right` holds the value and `Left` holds the error: ```scala import org.llm4s.llmconnect.model.Completion import org.llm4s.types.Result def basicPattern(result: Result[Completion]): String = result match { case Right(completion) => s"Response: ${completion.content}" case Left(error) => s"Error: ${error.message}" } ``` A completion's text is `completion.content`. `error.message` is for people; `error.formatted` adds the error's type, code and context, which is what to log. ## 3. Chaining with for-comprehensions Most programs make several calls that can each fail. A `for` comprehension runs them in order and stops at the first `Left`, which becomes the result. The calls after it do not run: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.llmconnect.model.Conversation import org.llm4s.model.ModelRegistryService import org.llm4s.types.Result def chained(): Result[String] = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) conversation <- Conversation.userOnly("What is Scala?") completion <- client.complete(conversation) } yield completion.content ``` Loading the configuration, building the client, building the conversation (which validates it) and making the call are four steps that can each fail, and you handle them once, at the end. `Llm4sConfig.defaultProvider()` reads the section `llm4s.providers.provider` names; see [Configuration](../getting-started/configuration#named-provider-sections). ## 4. Error types and when each is raised Every error is an `LLMError`, carrying a `message`, an optional `code` and a `context` map. The types in `org.llm4s.error` are each also marked as one of two kinds: - **Recoverable** (`RecoverableError`): the call may succeed if tried again, perhaps after you do something first (wait, re-read a record, correct the request). - **Non-recoverable** (`NonRecoverableError`): trying again will not help; something has to change. `LLMError.isRecoverable(error)` tells you which. It answers for every error: one that carries neither marker, as some errors from other modules do (see [errors defined by other modules](#errors-defined-by-other-modules)), is not recoverable, since nothing says a retry can help. | Error | Recoverable | Where the library raises it | |---|---|---| | `AuthenticationError` | no | A provider rejects the credentials: HTTP 401 or 403, Anthropic's `UnauthorizedException`, Bedrock's access-denied or missing-credentials exceptions, Gemini's invalid-key 400. Also when Vertex AI or watsonx cannot obtain a token, and from `DefaultErrorMapper` for an exception whose message names HTTP status 401 (`HTTP 401`, `401 Unauthorized`, `status code: 401`). | | `ConfigurationError` | no | Configuration is missing or invalid: no provider section, no API key, a provider id that no module on the classpath registers, an embedding model with no known dimensions, or a block a config loader cannot read (providers, embeddings, tracing, metrics, tools, RAG, speech). | | `ValidationError` | no | A request or value is rejected: an invalid message or conversation, a model the model registry does not know, a guardrail rejecting input or output (the built-in guardrails reject with one), HTTP 400 (on field `request`), Anthropic's or Bedrock's invalid-request exceptions, or a response body that cannot be parsed. | | `InvalidInputError` | no | Only `llm4s-image`'s image processing (`LocalImageProcessor`, saving an image): an unreadable path, a bad resize or crop, a path traversal. It carries the `field`, the `value` and the `reason`. | | `RateLimitError` | yes | A provider's rate limit: HTTP 429, or Anthropic's or Bedrock's throttling exception. Also `ReliableClient`'s own limiter, and `DefaultErrorMapper` for an exception whose message names HTTP status 429 (`HTTP 429`, `429 Too Many Requests`, `status code: 429`). It carries `retryAfter` when the provider says how long to wait. | | `ServiceError` | yes | Any other non-2xx status from a provider (through `HttpErrorMapper` or `Llm4sHttpClient`, and from Bedrock and watsonx), or a call rejected by an open circuit breaker (`ReliableClient` or `ErrorRecovery.CircuitBreaker`, status 503). It carries `httpStatus`; see the note below. | | `NetworkError` | yes | A connection fails, a host is unknown or I/O breaks, in llm4s's HTTP client or the Bedrock client; `DefaultErrorMapper`, which the OpenAI and Anthropic clients use for I/O failures, gives one for a socket timeout or a refused connection. Also a URL refused by the SSRF check, and a failed `llm4s-rag` URL, web-crawl or S3 load, including a non-2xx answer to `UrlLoader`. | | `TimeoutError` | yes | A connect, request or socket timeout elapses in llm4s's own HTTP client, or a `ReliableClient` deadline passes. The vendor-SDK clients map timeouts themselves: OpenAI and Bedrock report a `NetworkError`, and Anthropic maps its exceptions through `DefaultErrorMapper`. | | `APIError` | yes | Only `llm4s-image`'s vision clients (OpenAI, Anthropic, Gemini), through `LLMError.apiCallFailed`, when the vision API call fails or returns no text; it carries the provider and, optionally, a status code. Image generation has its own errors (see below). | | `ExecutionError` | yes | Only `ErrorRecovery.recoverWithBackoff`, when it runs out of attempts ([section 9](#9-recovering-from-failures)). Tool and MCP failures are other types; see the note below. | | `SystemError` | yes | Not raised by the library; available for your own code, for an unexpected failure that may be transient. | | `OptimisticLockFailure` | yes | Only `llm4s-memory-postgres`'s `PostgresMemoryStore`, when another writer updated the same memory record first; re-read it and try again. | | `CancelledError` | no | The thread was interrupted. Interruption is how llm4s cancels work, and a cancelled call is never retried. | | `ProcessingError` | no | A storage, parsing or processing step fails: a vector-store, keyword-index or memory-store operation, RAG document loading, extraction or permissions, knowledge-graph storage, extraction or queries, speech audio processing, image encoding or saving. | | `NotFoundError` | no | A memory store (in-memory, SQLite, Postgres) is asked to update a memory it does not hold, or `VectorStoreFactory` is given an unknown backend name. | | `ContextError` | no | Context compression fails: `LLMCompressor`'s LLM call fails, or `ToolOutputCompressor` cannot store an artifact or parse JSON content. | | `TokenizerError` | no | `ConversationTokenCounter` has no tokenizer for the requested id. | | `SimpleError` | no | The MCP client and its transports (`llm4s-mcp`): connection, session, JSON-RPC and server-process failures. | | `UnknownError` | no | An unexpected exception, wrapped: the fallback of `DefaultErrorMapper` (and so of `toResult` and `toLLMError`), an unexpected failure in llm4s's HTTP client, or a tracing backend that fails to start or to export. | The table lists the `LLMError` types in `org.llm4s.error`, the package that is the source of truth for this core set. Other modules define errors of their own, below. **Same name, different type.** A failed tool call is not an `org.llm4s.error.ExecutionError`. `ToolRegistry.execute`, and the MCP tool registry, return a `ToolCallError` (`org.llm4s.toolapi`), whose case for a tool that threw is `ToolCallError.ExecutionError`. `ToolCallError` is not an `LLMError`, and the agent hands it back to the model as the tool's result instead of failing the run. A graph's tool loop fails with `GraphError.ToolFailed`, described below. **`ServiceError` and its status.** The marker says a `ServiceError` is recoverable, but a 404 is not going to fix itself. When it matters, look at `httpStatus`: `error.isRecoverableStatus` (a method of `ServiceError`, no import needed) is true for 5xx, 429 and 408. The library's automatic retries retry a `ServiceError` only when it is; see the next paragraph. **Recoverable is not the same as retried automatically.** Every retry in the library - `recoverWithBackoff`, `ReliableClient`'s `RetryPolicy`, `LLMClientRetry` and the agent graph's default node retry - uses one rule, `RetryPolicy.isRetryable`: it resends the identical request with nobody in the loop, so it retries a recoverable error unless repeating the request cannot help. That leaves out two recoverable cases: a `ServiceError` or an `APIError` with a client-error status (any 4xx but 408 and 429), because the request itself is wrong, and an `OptimisticLockFailure`, because you must re-read the record first. An `APIError` with no status is retried. A non-recoverable error is never retried, and neither is an error that carries no marker. **Which status becomes which error.** The status mapping in the table is `HttpErrorMapper`'s: 401 and 403 to `AuthenticationError`, 429 to `RateLimitError`, 400 to `ValidationError`, any other non-2xx to `ServiceError`. The OpenAI, Azure, Requesty, Gemini, Vertex AI, Ollama, watsonx and OpenAI-compatible clients use it; Gemini first turns a 400 that reports an invalid API key into an `AuthenticationError`. The Anthropic client maps its SDK's exceptions instead: unauthorized (401) to `AuthenticationError`, rate limited to `RateLimitError` (with no `retryAfter`), invalid data to a `ValidationError` on field `input`, and anything else, including other HTTP statuses, through `DefaultErrorMapper` ([section 7](#7-turning-exceptions-into-errors)), usually to an `UnknownError`. The Bedrock client maps the AWS SDK's exceptions: throttling or an exceeded quota to `RateLimitError`, an invalid request to `ValidationError`, access denied, a 401 or 403, or missing credentials to `AuthenticationError`, any other service status to `ServiceError`, and any other client failure to `NetworkError`. **The next step, in the message.** A 401 or 403, a 429 or a 404 from `HttpErrorMapper` or the Anthropic client ends its message with one sentence saying what to do and a link to its entry in [First-Run Troubleshooting](/getting-started/troubleshooting), for example `... See https://llm4s.org/getting-started/troubleshooting#rate-limited`. The first-run configuration errors (a missing API key, no default provider, an unknown section, a provider that is not registered, an unreadable `application.conf`) end with their link and the [doctor](/reference/doctor)'s command. Only the text changes: match on the error's type and fields, never on its message. ### Errors defined by other modules Some modules add their own `LLMError` subtypes. The errors below carry **neither** marker, so `LLMError.isRecoverable` reports them as not recoverable and the library's automatic retries leave them alone. For each, the right answer depends on more than the type (a status inside a string `code`, a flag, a wrapped cause, which of several failures it reports), so none carries a marker: | Module | Package | Errors | |---|---|---| | `llm4s-core` | `org.llm4s.llmconnect.model` | `EmbeddingError`: how `EmbeddingClient.embed` and the embedding providers (OpenAI, Ollama, Voyage, Cohere, Jina) report a failure other than cancellation, except that the Cohere provider reports a 429 as a `RateLimitError` | | `llm4s-rag` | `org.llm4s.rag.evaluation`, `org.llm4s.reranker` | `EvaluationError` from RAGAS evaluation and the RAG benchmark tools; `RerankError` from the Cohere and LLM rerankers (`Reranker.rerank`) | | `llm4s-speech` | `org.llm4s.speech.stt`, `.tts` | `STTError.ProcessingFailed` from the speech-to-text clients (an empty transcription, no recognisable speech, an unparseable response; its `retryable` flag says `true`); `TTSError.SynthesisFailed` from the text-to-speech clients (an empty audio body) | The rest of those families are marked: | Module | Recoverable | Non-recoverable | |---|---|---| | `llm4s-speech` (`org.llm4s.speech.stt`, `.tts`, `.io`) | `STTError.EngineNotAvailable`, `TTSError.EngineNotAvailable` | `STTError.UnsupportedFormat`, `STTError.InvalidInput`, `WavFileGenerator.WavError`, `AudioIO.AudioIOError` | So are two whole families: `GraphError` in `llm4s-agent` (`org.llm4s.agent.graph`, from the graph runtime; every case is non-recoverable except `DeadlineExceeded` and `ThreadBusy`) and `ImageGenerationError` in `llm4s-image` (`org.llm4s.imagegeneration`, from the image-generation clients; a `ServiceError` there is recoverable only for a transient status). The image module reuses the names `AuthenticationError`, `RateLimitError`, `ServiceError`, `ValidationError` and `UnknownError`, so import those by package instead of with a wildcard next to `org.llm4s.error._`. Its vision clients (`org.llm4s.imageprocessing`) return the core errors in the table above. ## 5. Handling specific error types Match on the type to react differently to each failure. Put the specific cases first and finish with a catch-all: ```scala import org.llm4s.error._ import org.llm4s.types.Result def describe(result: Result[String]): String = result match { case Right(text) => s"ok: $text" case Left(e: RateLimitError) => s"wait ${e.retryDelay.getOrElse(RateLimitError.DefaultRetryDelay)}, then retry" case Left(e: AuthenticationError) => s"fix the credentials for ${e.provider}" case Left(e: RecoverableError) => s"transient, may succeed on retry: ${e.message}" case Left(e) => s"not retried (permanent, or not marked either way): ${e.message}" } ``` `RateLimitError.retryDelay` is the delay the provider asked for, or 30 seconds when it did not say. Two things to know: - **`LLMError` is not sealed**, so the compiler cannot tell you that a match is complete. Always end with a `case Left(e)`. - **A custom error must say what kind it is.** If you define your own error type, mix in `RecoverableError` or `NonRecoverableError` as well as `LLMError`. `LLMError.isRecoverable` reports a type that is neither as not recoverable, so a transient failure that forgets its marker is never retried. ```scala import org.llm4s.error.{ LLMError, NonRecoverableError } final case class VendorError(message: String) extends LLMError with NonRecoverableError ``` ## 6. Converting to exceptions (when you must) Sometimes a framework expects an exception: a test setup, a `main` that should crash on bad configuration, an API you do not control. Convert at the edge, in one place, and keep the error's details: ```scala import org.llm4s.types.Result def orThrow[A](result: Result[A]): A = result.fold(error => throw new RuntimeException(error.formatted), identity) ``` `error.formatted` carries the error's type and context, so the stack trace says what went wrong. Prefer `fold` to `result.getOrElse(throw new RuntimeException("LLM call failed"))`. The second compiles, but it discards the error, so the exception cannot say what went wrong. And do not use `result.getOrElse(default)` unless a silent fallback is what you want: a failure becomes the default with no trace. Do not throw from library code you write: return a `Result` and let the caller decide. ## 7. Turning exceptions into errors The other direction matters just as much, because the JDK and other libraries throw. Wrap them at the boundary so the rest of your code only sees `Result`: ```scala import org.llm4s.Result import org.llm4s.error.NotFoundError import org.llm4s.error.ThrowableOps._ import org.llm4s.types.{ OptionOps, TryOps } import scala.util.Try Try("123".toInt).toResult // Right(123) Try("abc".toInt).toResult // Left(...), the exception mapped to an LLMError Result.safely(1 / 0) // Left(...), a throwing block captured new IllegalStateException("boom").toLLMError // a Throwable as an LLMError Option.empty[String].toResult(NotFoundError("no such key", "model")) // Left(NotFoundError) ``` `toResult` and `toLLMError` map an exception through `DefaultErrorMapper`: an interrupt becomes a `CancelledError`, a socket timeout or a refused connection a `NetworkError`, an exception whose message names HTTP status 401 or 429 an `AuthenticationError` (code `401`, with the redacted message) or a `RateLimitError`, and anything else an `UnknownError` that keeps the exception. A message names a status only in an HTTP context, at a word boundary - after `HTTP`, `status`, `status code`, `error code` or `response code`, before its reason phrase (`401 Unauthorized`, `429 Too Many Requests`), or leading the message as `openai-java` and `anthropic-java` write it (`401: `) - so `Index 4012 out of bounds` or `port 14290` is an `UnknownError`. A JSON-style `"status": 429` or `http_status=401` counts too. The SDK shape has no other HTTP marker, so a message that merely *starts* with `401: ` or `429: ` is classified as well. Only the first 4 KiB and the last 1 KiB of a message are scanned. Pass your own `ErrorMapper` for a finer mapping. `Try` (and so `Result.safely`) never captures an `InterruptedException`, which Scala treats as fatal, so let an interrupt propagate or map it yourself with `toLLMError`. To make an error yourself, use the type's smart constructor: ```scala import org.llm4s.error.{ ConfigurationError, NotFoundError, ValidationError } ValidationError("model", "must not be empty") ConfigurationError("no provider configured", List("llm4s.providers.provider")) NotFoundError("no such key", "model") ``` ## 8. Combining results When you have a list of things to do, `Result` has helpers so you do not write the loop: ```scala import org.llm4s.Result import org.llm4s.types.{ Result, TryOps } import scala.util.Try def parseAll(inputs: List[String]): Result[List[Int]] = Result.traverse(inputs)(s => Try(s.toInt).toResult) parseAll(List("1", "2", "3")) // Right(List(1, 2, 3)) parseAll(List("1", "x", "y")) // Left(...), the first failure ``` - `Result.traverse` stops at the first failure: it does not call the function on the elements after it, so side effects and expensive work stop there too. `Result.sequence` returns the first failure in a list of results that have already been computed. - `Result.validateAll(items)(check)` runs every check and returns **all** the failures as a `Left(List[LLMError])`: use it when you want to report every problem at once. - `Result.combine(a, b)` joins two independent results into a tuple. ## 9. Recovering from failures A recoverable error is worth a retry. `ErrorRecovery.recoverWithBackoff` calls an operation up to `maxAttempts` times in all, waiting between attempts. Here `client` is any `LLMClient` and `conversation` a `Conversation`, built as in [section 3](#3-chaining-with-for-comprehensions): ```scala import org.llm4s.error.ErrorRecovery import scala.concurrent.duration._ val result = ErrorRecovery.recoverWithBackoff( () => client.complete(conversation), maxAttempts = 3, baseDelay = 1.second ) ``` It retries what every retry in the library retries (`RetryPolicy.isRetryable`; see [section 4](#4-error-types-and-when-each-is-raised)), each error on its own schedule. The delays are not exponential: | Error | Wait before the next attempt | |---|---| | `RateLimitError` | Its `retryDelay`: the provider's `retryAfter` when it gave one, else 30 seconds (`RateLimitError.DefaultRetryDelay`). `baseDelay` is not used. | | `ServiceError` with a 5xx, 429 or 408 status | The provider's `retryAfter` when it gave one, else `baseDelay` times the number of the attempt that failed: `baseDelay`, then `2 * baseDelay`, and so on. | | `TimeoutError` | `baseDelay`, every time. | | `NetworkError`, `ExecutionError`, `SystemError`, and an `APIError` with no status or a 5xx, 429 or 408 one | `baseDelay` times the number of the attempt that failed, as for a `ServiceError` without a hint. | Every other error comes back unchanged straight away, whichever attempt it happens on: a `ValidationError` on the last attempt, after a retried timeout, is still a `ValidationError`. That includes a `ServiceError` or an `APIError` with any other status and an `OptimisticLockFailure`. A `CancelledError` is never retried or wrapped, and an interrupt during a wait returns one. When the last attempt fails with a retried error, you get an `ExecutionError` whose message gives the number of attempts and the last error's message, and whose `operation` is the last error's `formatted` text; the original error's type is not kept. `ExecutionError` is itself a `RecoverableError` that the rule retries, so an outer `recoverWithBackoff` or `RetryPolicy` will retry it. The operation always runs at least once, even if `maxAttempts` is below 1. To stop calling a service that keeps failing, wrap the call in an `ErrorRecovery.CircuitBreaker`. After `failureThreshold` consecutive failures (a success resets the count) it opens, and further calls fail fast with a `ServiceError` without reaching the service, until `recoveryTimeout` has passed and a single probe call is allowed. For production use, `ReliableClient` wraps a client with retry (its own `RetryPolicy`, with configurable backoff), a circuit breaker, and an optional deadline and rate limit, in one place. See [Error Recovery](patterns/error-recovery) for retry strategies, fallbacks and graceful degradation. ## 10. Testing code that returns Result Check the `Right` and the `Left` the same way you would any value. With ScalaTest's `EitherValues`, `.value` unwraps a `Right` and `.left.value` unwraps a `Left`, failing the test with a clear message if it is the other one: ```scala import org.llm4s.error.ValidationError import org.llm4s.types.Result import org.scalatest.EitherValues import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec class ResultSpec extends AnyWordSpec with Matchers with EitherValues { "a Result" should { "unwrap with EitherValues, and check the error type" in { val ok: Result[String] = Right("expected") val failed: Result[String] = Left(ValidationError("model", "empty")) ok.value shouldBe "expected" ok.map(_.toUpperCase) shouldBe Right("EXPECTED") failed.left.value shouldBe a[ValidationError] failed.left.value.message should include("empty") } } } ``` To test code that calls an LLM without a network, give it a client that returns what you choose. See the [Testing Guide](../getting-started/testing-guide). ## Rules of thumb - Return `Result` from your own functions; do not throw. - Convert exceptions to errors where they enter your code, and errors to exceptions only where a framework forces you to, in one place. - Match on specific types first, then on `RecoverableError`, then a catch-all. `LLMError.isRecoverable` asks the same question of any error. - Retry only what is recoverable, with a limit and a delay; never retry a `CancelledError`. The library's own retries also leave out a client-error status and an `OptimisticLockFailure`. - Log `error.formatted`, show `error.message`, and never put an API key in either. ## See also - [Basic Usage](basic-usage): your first calls and the `Result` type - [Error Recovery](patterns/error-recovery): retries, circuit breakers and fallbacks - [Configuration](../getting-started/configuration): named provider sections and API keys - [Testing Guide](../getting-started/testing-guide): testing with mock clients --- Source: https://llm4s.org/guide/structured-output # Structured Output with completeStructured Ask a model for a typed value instead of free text, and know what you get back when it does not comply. --- ## 1. What it does `LLMClient.completeStructured[A]` sends a conversation, asks the provider for JSON that matches a schema, and returns the reply as a value of type `A`: (not compile-checked) ```scala import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ Conversation, UserMessage } import org.llm4s.toolapi.Schema import org.llm4s.types.Result import upickle.default.{ macroRW, ReadWriter } final case class Invoice(vendor: String, amount: Double, currency: String) object Invoice { implicit val rw: ReadWriter[Invoice] = macroRW } val invoiceSchema = Schema .`object`[Invoice]("An invoice extracted from text") .withRequiredField("vendor", Schema.string("Name of the vendor or supplier")) .withRequiredField("amount", Schema.number("Total invoice amount as a decimal number")) .withRequiredField("currency", Schema.string("ISO 4217 currency code, e.g. USD, EUR, GBP")) def extractInvoice(client: LLMClient, text: String): Result[Invoice] = client.completeStructured[Invoice]( Conversation(Seq(UserMessage(s"Extract the invoice from this text:\n$text"))), invoiceSchema ) ``` The snippets below build on these definitions (`Invoice`, `invoiceSchema` and `extractInvoice`) and import what else they use. The result is a `Result[Invoice]`: `Right(Invoice("Acme Supplies Ltd", 1250.0, "GBP"))` for a reply of `{"vendor":"Acme Supplies Ltd","amount":1250.0,"currency":"GBP"}`. Other replies that can be deserialised as `Invoice` also return `Right`; replies that cannot be deserialised return `Left` (section 7). A runnable version that builds the client from configuration is `StructuredOutputExample`: ```bash sbt "samples/runMain org.llm4s.samples.basic.StructuredOutputExample" ``` Use it for extraction, classification, routing and any step whose output a program, not a person, reads. Streaming is not part of it: `completeStructured` makes one non-streaming `complete` call. ## 2. Describing the type Two things describe `A`, and they have to agree: - **The schema**, an `ObjectSchema[A]` built with `Schema.\`object\``, `withRequiredField` and `withProperty`. It is what the provider is shown. The same builders define tool parameters; see [Agents and tools](agents/). - **A uPickle reader**, an implicit `upickle.default.Reader[A]` (a `ReadWriter` from `macroRW` is the usual way). It is what turns the reply into `A`. Nothing checks that the two match, and some mismatches are never reported. A field the case class has but the schema lacks fails to parse when the reply omits it (section 7), unless the field has a default, which the reader fills in silently. A field the schema has but the case class lacks is worse: the provider returns it, the reader ignores it as an extra key, and the result is `Right(A)` with that value dropped. Keep the schema and the case class in step by hand, and test the pair on a sample reply. The schema is a closed object: `additionalProperties` is `false` unless you pass `true` to `ObjectSchema`. `llm4s-schema-derivation` removes the need to keep the two in step: `client.completeStructuredOf[A](conversation)` derives the schema from `A` (`derives SchemaOf`); see [Derived Schemas](derived-schemas). ### Sum types When the answer is one of several shapes ("refund this amount", "escalate to this team" or "reply with this text"), make `A` an enum or sealed trait whose cases have fields, deriving `SchemaOf` and `ReadWriter`, and call `completeStructuredOf[A]`. The derived schema is a JSON Schema `anyOf` of one object per case, each tagged with the discriminator uPickle writes (`"$type"`, or the trait's `@upickle.implicits.key`), so the reply reads back into the right case. Because OpenAI's strict mode refuses an `anyOf` at the root, the request asks for `{"value": }` and the reply is unwrapped before it is read; the caller only sees `A`. The request is a `ResponseFormat.JsonSchema` that core's `ObjectSchema` could not express, sent the same way to every provider (section 8). [Derived Schemas: Sum types](derived-schemas#sum-types) has the example, compiled, and the exact shape of the schema. ## 3. Fenced and prose-wrapped replies Models often wrap JSON in a markdown fence or write a sentence around it. Before parsing, the reply is normalised: a surrounding code fence is stripped, and if the rest is still not JSON, the first balanced `{...}` or `[...]` that parses as JSON is taken from it. Both of these give the same `Invoice`: ````text ```json {"vendor":"Acme Supplies Ltd","amount":1250.0,"currency":"GBP"} ``` Sure! Here is the invoice you asked for: {"vendor":"Acme Supplies Ltd","amount":1250.0,"currency":"GBP"}. Let me know if you need anything else. ```` The recovery is bounded: it tries at most 16 places where a `{` or `[` starts, so a long reply full of braces that are not JSON is not searched forever. If it finds nothing, the trimmed text goes on to the parser, which reports the failure. That function, `LLMClient.extractJson`, is internal to llm4s (`private[llmconnect]`). You cannot call it, and code that calls `complete` and parses the reply itself gets no recovery (section 6). ## 4. What is sent, and why every property is required `completeStructured` turns your schema into JSON Schema with `strict = true`, wraps it in `ResponseFormat.JsonSchema(schema)` and sets that as the `responseFormat` of the call. `strict = true` lists **every** property under `required`, including a property you declared optional: ```scala import org.llm4s.toolapi.Schema val note = Schema .`object`[Map[String, String]]("A note") .withRequiredField("vendor", Schema.string("Vendor")) .withProperty(Schema.property("note", Schema.string("Free-text note"), required = false)) note.toJsonSchema(strict = true)("required") // ["vendor", "note"] note.toJsonSchema(strict = false)("required") // ["vendor"] ``` So `required = false` does nothing through `completeStructured`: the provider is told `note` is required, and a model that follows the schema always includes it. If you need a value that may be absent, make the field's type allow it (`Schema.nullable(...)`, with an `Option` in the case class) instead of leaving the field out. The `name` and `strict` fields of the format are the defaults, `"response"` and `true`. They are not parameters of `completeStructured`; section 6 shows how to set them. ## 5. Options `completeStructured` keeps every option you pass and replaces only `responseFormat`: (not compile-checked) ```scala import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ CompletionOptions, Conversation, UserMessage } import org.llm4s.types.Result def deterministic(client: LLMClient, text: String): Result[Invoice] = client.completeStructured[Invoice]( Conversation(Seq(UserMessage(text))), invoiceSchema, CompletionOptions().withTemperature(0.0).withMaxTokens(256) ) ``` Temperature, token limit and the rest are passed to `complete` as given. The provider client may still adjust them for the model, as it does for any `complete` call: OpenAI reasoning models, for example, omit sampling parameters, and an option the model does not support can be dropped. A `responseFormat` you set yourself is overwritten by the schema's, so `completeStructured` is not a way to request plain JSON mode; call `complete` with `ResponseFormat.Json` for that. ## 6. Choosing the schema name or strictness For a name other than `"response"`, or `strict = false`, call `complete` with your own format and read the reply yourself: (not compile-checked) ```scala import org.llm4s.error.ValidationError import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ CompletionOptions, Conversation, ResponseFormat, UserMessage } import org.llm4s.types.Result def withCustomFormat(client: LLMClient, text: String): Result[Invoice] = { val format = ResponseFormat.JsonSchema( invoiceSchema.toJsonSchema(strict = false), name = "invoice", strict = false ) for { completion <- client.complete(Conversation(Seq(UserMessage(text))), CompletionOptions().withResponseFormat(format)) // No fence or prose recovery on this path: the reply has to be bare JSON. invoice <- scala.util.Try(upickle.default.read[Invoice](completion.content)).toEither.left.map(e => ValidationError("invoice", e.getMessage) ) } yield invoice } ``` `name` and `strict` only mean something to providers that have an equivalent (section 8). Where there is none, as with Ollama, they are ignored. ## 7. When the reply is not usable A reply that cannot become an `A` is a `ValidationError` whose `field` is `"structured_output"`: | The reply | `error.message` contains | |---|---| | text with no JSON in it | `Response is not valid JSON` | | the JSON value `null` | `Response does not match expected schema: got JSON null` | | JSON of the wrong shape (an array for an object, a missing field, a wrong type) | `Response does not match expected schema` | The reply is deserialised, not validated against the schema. A missing field or a wrong type fails because the uPickle reader for `A` fails on it, but a constraint the reader does not check is not checked at all: an enum, a numeric or string bound, or `additionalProperties = false` (extra keys are ignored). A reply that breaks one of those still comes back as `Right(A)`, which matters most where the provider does not enforce the schema (section 8). Check such constraints on the result yourself. A failed provider call is a different error, returned unchanged (a `NetworkError`, `RateLimitError` and so on; see [Error Handling](error-handling)). Telling the two apart is a pattern match: (not compile-checked) ```scala import org.llm4s.error.ValidationError import org.llm4s.types.Result def describe(result: Result[Invoice]): String = result match { case Right(invoice) => s"ok: ${invoice.vendor}" case Left(error: ValidationError) if error.field == "structured_output" => s"model reply rejected: ${error.message}" case Left(error) => s"call failed: ${error.message}" } ``` A rejected reply is often worth one retry. A failed call is handled by whatever retry you already use (a `ReliableClient` retries transient provider errors, but never a `ValidationError`): (not compile-checked) ```scala import org.llm4s.error.ValidationError import org.llm4s.llmconnect.LLMClient import org.llm4s.types.Result def retryOnce(client: LLMClient, text: String): Result[Invoice] = extractInvoice(client, text) match { case Left(error: ValidationError) if error.field == "structured_output" => extractInvoice(client, text) case other => other } ``` `ValidationError`s are not marked recoverable on their own, so decide per call site whether a retry is reasonable. A model that refuses, or answers in prose again, will fail again. ## 8. What each provider does with the schema The format is a request, not a guarantee. This is what each client does with `ResponseFormat.JsonSchema`, taken from its code: | Provider | What is sent | Enforced? | |---|---|---| | OpenAI, Azure OpenAI | `response_format` of type `json_schema`, with your `name` and `strict` | Yes: the provider constrains generation to the schema | | Requesty | The same OpenAI `json_schema` shape, with your `name` and `strict` | Depends on the routed model: Requesty is a multi-provider router, so enforcement is up to the backend model or fallback it selects | | openai-compatible: generic, DeepSeek, Z.ai, OpenRouter, Mistral | The same OpenAI `json_schema` shape, with your `name` and `strict` | The server decides: a server that ignores `response_format` returns unconstrained text | | Cohere (through its OpenAI-compatibility API) | `response_format` of type `json_object` carrying the `schema`; `name` and `strict` are not sent | Cohere's own mechanism | | Gemini, Vertex AI | `responseMimeType: application/json` and `responseSchema` set to the schema | Yes, by Gemini | | Ollama | The schema as the top-level `format` field of `/api/chat` | Yes, on Ollama 0.5 or later; an older server rejects or ignores it. `name` and `strict` are ignored | | Anthropic | **Nothing is enforced.** The schema is appended to the system prompt as an instruction to answer with JSON only | **No**: best effort. Expect section 7 errors more often | | watsonx (Beta) | `response_format` of type `json_schema`, with your `name` and `strict` (`ResponseFormat.Json` as `json_object`) | Depends on the model; not yet confirmed against the live service ([#1314](https://github.com/llm4s/llm4s/issues/1314)) | | AWS Bedrock | **Nothing.** This client does not read `responseFormat` | **No**: the model sees only your conversation | For the last row, `completeStructured` still parses whatever comes back, so a model that happens to answer in JSON works and one that does not gives a `ValidationError`. Ask for JSON in the prompt yourself when you use that provider. ## 9. Limits and gotchas - **The model registry can switch the schema off.** The OpenAI, Anthropic, Gemini, Vertex AI and Bedrock clients check the options against the model registry first. When the registry records that a model does not support response schemas, the schema is dropped without an error (and with it Anthropic's prompt instruction), and `completeStructured` then parses an unconstrained reply. - **Every property is required** (section 4), whatever you declared. - **Integers above 2^53.** A uPickle `Long` field reads a number such as 9007199254740993 exactly, because `completeStructured` reads `A` from the reply's text. A reader that starts from `ujson.Value` (the circe, play-json and zio-json bridges) and every tool argument get the rounded double instead, so send large identifiers as strings there; see [Numbers](json-libraries#numbers). - **One call, no streaming, no repair.** There is no automatic retry inside `completeStructured`. - **The content goes to the provider.** The conversation and the schema are sent to whichever provider the client talks to, like any other completion. - **The schema is JSON Schema, not your case class.** A type uPickle can read but `ObjectSchema` cannot describe (a map with free keys, a recursive type) needs a schema that matches what the model should produce, and a reader that accepts it. A sum type is described by `completeStructuredOf` ([Sum types](#sum-types)). ## Related - [Error Handling](error-handling): every `LLMError`, and how to combine results. - [Providers](providers): configuring each provider. - `StructuredOutputExample` in `modules/samples`: the runnable version of section 1. --- Source: https://llm4s.org/guide/spring-boot # Using LLM4S with Spring Boot Add `llm4s-spring-boot-starter` and inject an `LLM4STemplate`: the client, an executor for asynchronous calls and a health indicator are configured from your `application.properties`. --- ## What the starter gives you The starter is Spring Boot auto-configuration on top of the [Java API](java). When it is on the classpath it registers: | Bean | What it is | Replace it by | |---|---|---| | `llm4sClient` | the `JLlmClient`, built from the `llm4s.*` properties; closed when the context closes | defining a bean of type `JLlmClient` | | `llm4sTaskExecutor` | the `ExecutorService` behind `completeAsync` and the health probe | defining a bean named `llm4sTaskExecutor` | | `llm4sTemplate` | the `LLM4STemplate` you inject | defining a bean of type `LLM4STemplate` | | `llmHealthIndicator` | an Actuator `HealthIndicator`, only when Spring Boot Actuator is on the classpath | defining a bean of type `LlmHealthIndicator` (`LlmBoot4HealthIndicator` on Spring Boot 4) | | `llm4sMetricsCollector` | a `MicrometerMetrics` over your `MeterRegistry`, only when `llm4s-observability-micrometer` is on the classpath and there is a `MeterRegistry` bean; the client reports every call to it (see [Metrics](#metrics)) | defining a bean of type `MetricsCollector` | Set `llm4s.enabled=false` to switch all of them off; the beans are also registered when `llm4s.enabled` is absent. **Spring Boot versions.** One artifact supports Spring Boot 3.x from 3.3 on and Spring Boot 4.x from 4.0 on. It is built against 3.3.6, and CI runs its whole test suite again against the latest 3.x release (3.5.16), against the 4.0 line (4.0.8) and against the latest 4.x release (4.1.1), so both ends of each range are tested on every change; the Spring Boot 4 runs test the published classes, not a Boot 4 build of them. The one difference is the health indicator: Spring Boot 4 moved `HealthIndicator` to `org.springframework.boot.health.contributor`, so there the bean `llmHealthIndicator` is an `LlmBoot4HealthIndicator`, reporting exactly what `LlmHealthIndicator` reports on 3.x. > `llm4s-spring-boot-starter` is on Maven Central from `0.5.0` on, together with the Java API it is built on; the > current release is 0.5.1. It needs **JDK 21**, like the Java API (see > [Scala and JDK support](/reference/v1-scope#scala-and-jdk-support)); whether some artifacts get a lower floor before > 1.0 is tracked in [#1493](https://github.com/llm4s/llm4s/issues/1493). ## Add the dependency The artifact is `org.llm4s:llm4s-spring-boot-starter_3`. The `_3` is the Scala binary version (LLM4S is Scala 3 only), and Maven and Gradle need it spelled out. **Maven** ```xml org.llm4s llm4s-spring-boot-starter_3 0.5.1 ``` **Gradle (Kotlin DSL)** ```kotlin dependencies { implementation("org.llm4s:llm4s-spring-boot-starter_3:0.5.1") } ``` It brings the Java API, so the [dependency notes of the Java guide](java#add-the-dependency) apply: the provider modules, Scala's standard library, and no logging backend. For the health indicator, add `spring-boot-starter-actuator` yourself: the starter declares it as `provided`, so it is not on your classpath unless you add it. ## Configure it The starter reads Spring properties under the `llm4s` prefix. It does **not** read the HOCON `application.conf` that the [Java guide](java#configure-a-provider) uses: here the provider, model and key are properties. ```properties llm4s.provider=openai llm4s.model=gpt-4o llm4s.api-key=${OPENAI_API_KEY} ``` The same in YAML: ```yaml llm4s: provider: openai model: gpt-4o api-key: ${OPENAI_API_KEY} ``` `llm4s.provider` and `llm4s.model` are required, and so is `llm4s.api-key` for OpenAI and Anthropic. (These flat keys serve OpenAI, Anthropic and Ollama; every other provider is configured under `llm4s.providers`, see [Any provider](#any-provider).) A missing one stops the application context from starting, with a message that names the property. Put the key in an environment variable or a secrets store and refer to it with a placeholder, as above; do not commit it. ### Properties | Property | Default | Meaning | |---|---|---| | `llm4s.enabled` | `true` | `false` registers none of the beans | | `llm4s.provider` | empty | required without `llm4s.providers.*`: `openai`, `anthropic` or `ollama` | | `llm4s.model` | empty | required: the model name as the provider spells it | | `llm4s.api-key` | empty | required for `openai` and `anthropic`; not used by `ollama` | | `llm4s.base-url` | empty | overrides the provider's default URL (below) | | `llm4s.organization` | empty | OpenAI only: the organisation id | | `llm4s.context-window` | `128000` | the model's context window in tokens; must be positive | | `llm4s.reserve-completion` | `4096` | tokens kept for the reply; at least 0 and less than the context window | | `llm4s.async.max-threads` | `16` | worker threads of the default executor | | `llm4s.async.queue-capacity` | `1000` | calls queued before `completeAsync` fails its future | | `llm4s.health.probe` | `false` | opt in to a real provider call per health check | | `llm4s.health.probe-ttl` | `60s` | how long a probe result is reused | | `llm4s.health.probe-timeout` | `10s` | a slower probe is cancelled and reports `DOWN` | The starter ships Spring Boot configuration metadata (`META-INF/spring-configuration-metadata.json`), so IntelliJ IDEA and the Spring tools for VS Code and Eclipse complete these keys, show their defaults and flag a misspelt one in `application.properties` and `application.yml`. Inside a `llm4s.providers.` section they complete nothing: its keys depend on the provider, and are listed under [Any provider](#any-provider). ### Which providers The flat `llm4s.provider` key supports three providers: | `llm4s.provider` | API key | Default `llm4s.base-url` | |---|---|---| | `openai` | required | `https://api.openai.com/v1` | | `anthropic` | required | `https://api.anthropic.com` | | `ollama` | not used | `http://localhost:11434` | Any other value stops the context with `Unknown provider: ''. The flat llm4s.provider key supports openai, anthropic and ollama; configure any other provider under llm4s.providers.`, followed by the providers on the classpath. ### Any provider Every other provider (Gemini, Azure OpenAI, Mistral, OpenRouter, a self-hosted OpenAI-compatible server, or a provider module you add) is configured under `llm4s.providers`. These properties mirror the `llm4s.providers` block of llm4s's own HOCON configuration, so any provider on the classpath works, with the extra keys it declares. `llm4s.providers.provider` names the section the starter uses: ```properties llm4s.providers.provider=gemini-main llm4s.providers.gemini-main.provider=gemini llm4s.providers.gemini-main.model=gemini-2.0-flash llm4s.providers.gemini-main.api-key=${GOOGLE_API_KEY} ``` Azure OpenAI takes its resource endpoint and API version as keys of the section: ```properties llm4s.providers.provider=azure-main llm4s.providers.azure-main.provider=azure llm4s.providers.azure-main.model=my-deployment llm4s.providers.azure-main.api-key=${AZURE_OPENAI_API_KEY} llm4s.providers.azure-main.endpoint=https://my-resource.openai.azure.com llm4s.providers.azure-main.api-version=2024-02-01 ``` A server that speaks the OpenAI chat-completions format needs only its URL and model; the generic `openai-compatible` provider also reads `context-window`, `reserve-completion` and `headers.*`: ```properties llm4s.providers.provider=local llm4s.providers.local.provider=openai-compatible llm4s.providers.local.model=my-model llm4s.providers.local.base-url=http://localhost:8000/v1 llm4s.providers.local.context-window=32000 llm4s.providers.local.headers.X-Team-Id=team-7 ``` - `kebab-case` and `snake_case` names in a section are read as the `camelCase` names of the HOCON block (`api-key` is `apiKey`, `api-version` is `apiVersion`); section and header names are kept as written. - A section with no `api-key` uses the vendor's shared `llm4s.credentials..api-key`, then the vendor's conventional environment variable (`GOOGLE_API_KEY`, `MISTRAL_API_KEY`, ...). - An `LLM4S_PROVIDERS_*` environment variable cannot add a setting; it only overrides one already declared in a property source (Spring's relaxed binding). Pass a secret with a placeholder (`api-key=${MY_KEY}`), or leave `api-key` out and set the vendor's environment variable, which reaches the section through `llm4s.credentials`. - As soon as any `llm4s.providers.*` property is set, the block is used and the flat `llm4s.model`, `llm4s.api-key`, ... are ignored. With none, the flat keys are read exactly as before. - An unknown provider id in a section stops the context with llm4s's own message, which names the registered providers and the dependency to add. - Behind an outbound proxy or a corporate CA, the `llm4s.http.*` properties - `llm4s.http.proxy.url`, `.username`, `.password`, `.non-proxy-hosts` (a list), `llm4s.http.tls.trust-store`, `.trust-store-password`, `.key-store`, `.key-store-password` - apply to the client in both forms, the flat keys and `llm4s.providers.*`, as [`llm4s.http`](../getting-started/configuration#corporate-networks-a-proxy-and-a-custom-ca) does in HOCON. A section's own `llm4s.providers..http.*` overrides them for that section. You can also define your own `JLlmClient` bean, for example from an `application.conf` as the Java guide describes. The starter's client backs off, and the template and the health indicator use yours; `llm4s.provider` and the other properties are then not needed: ```java import org.llm4s.javaapi.JLlmClient; import org.llm4s.javaapi.Llm4s; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class OtherProviderConfig { @Bean JLlmClient llm4sClient() { return Llm4s.createDefaultClient().get(); } } ``` `get()` throws an `LlmException` when the `application.conf` is wrong, which stops the context with its message. ## Use the template `LLM4STemplate` is the bean to inject. It wraps the client: `complete` blocks the calling thread and returns the reply text, `tryComplete` blocks and returns an `LlmResult` instead of throwing, and `completeAsync` does not block. ```java import org.llm4s.spring.LLM4STemplate; import org.springframework.stereotype.Service; @Service public class SummaryService { private final LLM4STemplate llm; public SummaryService(LLM4STemplate llm) { this.llm = llm; } public String summarise(String text) { return llm.complete("Summarise in one sentence: " + text); } } ``` Each of the three takes a plain `String` or a `Conversation` (built with the Java API's `ConversationBuilder`); `complete` also has a form that takes `CompletionOptions`. ### When a call fails `complete` throws `LlmException` when the call fails. `tryComplete` returns the failure as a value: ```java try { String answer = llm.complete("What is 2+2?"); System.out.println(answer); } catch (LlmException e) { System.err.println(e.getMessage()); } LlmResult result = llm.tryComplete("What is 2+2?"); ``` `LlmException` and `LlmResult` are in `org.llm4s.javaapi` and are described in the [Java guide](java#handling-a-failure). ### Asynchronous calls `completeAsync` returns a `CompletableFuture` straight away. The blocking provider call runs on the `llm4sTaskExecutor` bean and the future completes from there: ```java CompletableFuture reply = llm.completeAsync("Translate to French: " + text); reply.thenAccept(answer -> System.out.println(answer)); ``` It is [`JLlmClient.completeAsync`](java#asynchronous-calls) on that executor. A failed call completes the future exceptionally with the `LlmException`. `reply.cancel(true)` interrupts the provider call. The default executor is a bounded pool of daemon threads named `llm4s-async-N`, sized by `llm4s.async.*`; when its queue is full the returned future fails with an `LlmException` of kind `OTHER` whose cause is the `RejectedExecutionException`, and the call does not block. It is shut down with `shutdownNow` when the context closes, which interrupts calls still running. A `llm4sTaskExecutor` bean of your own replaces it and can be any `ExecutorService`, a `ForkJoinPool` included: the future interrupts the thread running its call itself, rather than relying on the executor's `Future.cancel(true)`. ### A web endpoint A controller calls the service like any other Spring bean. This one is not compiled with the other snippets, because it needs `spring-boot-starter-web`, which the starter does not depend on: ```java // illustrative: needs spring-boot-starter-web, which the starter does not depend on @RestController class SummaryController { private final SummaryService summaries; SummaryController(SummaryService summaries) { this.summaries = summaries; } @PostMapping("/summaries") String summarise(@RequestBody String text) { return summaries.summarise(text); } } ``` ## Health With Actuator on the classpath the starter adds a health indicator, the bean `llmHealthIndicator`. By default it makes **no provider call**: it reports `UP` with the details `provider`, `model` and `probe=disabled`, which means "configured", not "reachable". The API key is never reported. With `llm4s.health.probe=true` it sends a one-token completion on the executor, waits at most `llm4s.health.probe-timeout`, and reuses the outcome (a failure too) for `llm4s.health.probe-ttl`, so health polling does not call the provider each time. A failed or timed-out probe is `DOWN` with `probe=failed` or `probe=timeout` and an `error` message that has the API key and token-like strings removed. Every credential of the resolved provider is removed, wherever it was set (a Spring property, `application.conf`, a `reference.conf` default or an environment variable) and however deep it sits in the provider's config: Bedrock's access key id, secret key and session token, a workload-identity token, and every `headers` value, whatever the header is called, along with the token after an auth scheme such as `Bearer`. A health check whose thread is interrupted while it waits (Actuator shutting down, say) is `DOWN` with `probe=cancelled` and the interrupt flag left set; `health()` never throws `InterruptedException`. A cancelled probe is not cached: it says something about that thread, not the provider, so the next check probes again. A probe is a billed call: keep the TTL generous. ## Metrics Add `llm4s-observability-micrometer` next to `spring-boot-starter-actuator`, and the client records request, duration, token, cost and error meters (`llm4s.requests`, `llm4s.request.duration`, `llm4s.tokens`, ...) in the application's `MeterRegistry`, with no code: they are on `/actuator/metrics` and go wherever your registry exports (Prometheus, Datadog, CloudWatch, OTLP). The starter declares the module `provided`, as it does Actuator, so nothing Micrometer reaches your classpath unless you add it. ```xml org.llm4s llm4s-observability-micrometer_3 0.5.1 ``` A `MetricsCollector` bean of your own replaces the Micrometer one, and the client reports to every `MetricsCollector` bean in the context, so a cost tracker can run next to it. The meters, their tags and the matching Prometheus series are in [Micrometer metrics](observability/micrometer). ## Testing code that uses the template There is no supported fake client for tests yet ([#1497](https://github.com/llm4s/llm4s/issues/1497)). What works today: a test that does not need the model can run with `llm4s.enabled=false`, so no client is built and no key is needed; put the template behind an interface of your own, which that test replaces. And because the starter's beans back off for a bean of the same type, a test configuration can supply its own `JLlmClient`. ## Where next - [Java](java): the client, conversations and failures the template is built on. - [Providers](providers) and [configuration](../getting-started/configuration): every provider, for the HOCON route. - The starter's own [README](https://github.com/llm4s/llm4s/blob/main/modules/spring-boot-starter/README.md). --- Source: https://llm4s.org/guide/image-generation # Image Generation with LLM4S The LLM4S library provides a powerful and flexible API for generating images using different providers like Stable Diffusion (via a local WebUI) and the Hugging Face Inference API. --- ## Core Concepts The API is centered around the `ImageGenerationClient` trait, which defines the common interface for interacting with different image generation services. You can get a pre-configured client using the `ImageGeneration` factory object. Key models include: - `ImageGenerationConfig`: A trait for provider-specific configurations (`StableDiffusionConfig`, `HuggingFaceConfig`). - `ImageGenerationOptions`: A class to control generation parameters like image size, seed, and negative prompts. - `GeneratedImage`: A case class representing the generated image, including its data and metadata. --- ## Using the Stable Diffusion Client This client connects to an instance of the [AUTOMATIC1111/stable-diffusion-webui](https://github.com/AUTOMATIC1111/stable-diffusion-webui). You must have this running locally or on a remote server. ### Setup First, ensure your `build.sbt` includes the LLM4S dependencies. Image generation is in `llm4s-image`. (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-core" % "0.5.1" libraryDependencies += "org.llm4s" %% "llm4s-image" % "0.5.1" ``` ### Example Usage Here's how to create a client and generate an image: (not compile-checked) ```scala import org.llm4s.imagegeneration._ import java.nio.file.Paths // 1. Create a client for a local Stable Diffusion server val sdClient = ImageGeneration.stableDiffusionClient( baseUrl = "http://localhost:7860" // Default URL ) // 2. Define a prompt and generation options val prompt = "A photorealistic portrait of a majestic lion" val options = ImageGenerationOptions( size = ImageSize.Square512, negativePrompt = Some("cartoon, drawing, sketch, blurry") ) // 3. Generate the image println("Generating image with Stable Diffusion...") sdClient.generateImage(prompt, options) match { case Right(image) => println("Image generated successfully!") // Save the image val path = Paths.get("stable_diffusion_lion.png") image.saveToFile(path) println(s"Image saved to ${path.toAbsolutePath}") case Left(error) => println(s"Error generating image: ${error.message}") } ``` --- ## Using the Hugging Face Client This client uses the Hugging Face Inference API, which requires an API token. ### Setup You will need a Hugging Face account and an API token with write permissions. ### Example Usage The process is very similar. The main difference is the configuration. (not compile-checked) ```scala import org.llm4s.imagegeneration._ import java.nio.file.Paths // 1. Get your API token (it's best to use an environment variable) val hfApiKey = sys.env.getOrElse("HF_API_TOKEN", "your_hf_api_token_here") if (hfApiKey == "your_hf_api_token_here") { println("Please set the HF_API_TOKEN environment variable.") } else { // 2. Create a Hugging Face client val hfClient = ImageGeneration.huggingFaceClient( apiKey = hfApiKey, model = "runwayml/stable-diffusion-v1-5" // You can choose other models ) // 3. Define a prompt val prompt = "A cute robot reading a book, sci-fi concept art" // 4. Generate the image println("Generating image with Hugging Face...") hfClient.generateImage(prompt) match { case Right(image) => println("Image generated successfully!") val path = Paths.get("hugging_face_robot.png") image.saveToFile(path) println(s"Image saved to ${path.toAbsolutePath}") case Left(error) => println(s"Error generating image: ${error.message}") } } ``` --- ## Customizing Generation The `ImageGenerationOptions` class allows you to fine-tune the generation process: | Option | Type | Description | |--------|------|-------------| | `size` | `ImageSize` | The dimensions of the output image (e.g., `ImageSize.Square512`) | | `format` | `ImageMediaType` | The image format, from `org.llm4s.media` (`MediaType.Png` or `MediaType.Jpeg`) | | `seed` | `Option[Long]` | A specific seed for reproducible results | | `guidanceScale` | `Double` | How strictly the model should follow the prompt | | `inferenceSteps` | `Int` | The number of steps in the diffusion process | | `negativePrompt` | `Option[String]` | A prompt describing what you *don't* want to see | --- ## See Also - [Examples](/examples/) - Working code examples - [API Reference](/api/llm-client) - Complete API documentation --- Source: https://llm4s.org/guide/speech # LLM4S Speech Module A comprehensive speech recognition and text-to-speech synthesis module for the LLM4S project, built with Scala and functional programming principles. --- ## Features ### Speech Recognition (STT) - **Vosk**: Lightweight, offline speech recognition engine - **Whisper**: High-accuracy transcription via CLI integration - **Audio Preprocessing**: Resampling, channel conversion, silence trimming - **Multiple Input Formats**: File, bytes, and stream audio support - **Cloud**: `OpenAISTTClient` (Whisper API) and `AzureSTTClient` (Azure AI Speech), see [Cloud providers](#cloud-providers) ### Text-to-Speech (TTS) - **Tacotron2**: Neural speech synthesis via CLI integration - **Cloud**: `OpenAITTSClient`, `ElevenLabsTTSClient` and `AzureTTSClient`, see [Cloud providers](#cloud-providers) - **Voice Customization**: Language, speaking rate, pitch, volume control - **Output Formats**: WAV and raw PCM16 audio support - **Cross-platform**: Works on Windows, Linux, and macOS --- ## Architecture The module follows functional programming principles with: - **Result Types**: `Either[LLMError, T]` for error handling - **Pure Functions**: Immutable audio transformations - **ADTs**: Algebraic Data Types for type-safe modeling - **Composition**: Functional composition for audio processing pipelines --- ## Quick Start ### Basic Usage (not compile-checked) ```scala import org.llm4s.speech._ import org.llm4s.speech.stt.{WhisperSpeechToText, STTOptions} import org.llm4s.speech.tts.{Tacotron2TextToSpeech, TTSOptions} import org.llm4s.speech.util.PlatformCommands // Speech Recognition val stt = new WhisperSpeechToText(PlatformCommands.mockSuccess) val audioInput = AudioInput.FileAudio(Paths.get("audio.wav")) val options = STTOptions(language = Some("en")) val result = stt.transcribe(audioInput, options) // Text-to-Speech val tts = new Tacotron2TextToSpeech(PlatformCommands.echo) val ttsOptions = TTSOptions( voice = Some("en-female"), language = Some("en"), speakingRate = Some(1.2) ) val audio = tts.synthesize("Hello, world!", ttsOptions) ``` ### Audio Preprocessing (not compile-checked) ```scala import org.llm4s.speech.processing.AudioPreprocessing val audioBytes = // ... your audio data val audioMeta = AudioMeta(sampleRate = 44100, numChannels = 2, bitDepth = 16) // Convert to mono, resample to 16kHz for STT val processed = AudioPreprocessing.standardizeForSTT( audioBytes, audioMeta, targetRate = 16000 ) ``` `AudioPreprocessing.resamplePcm16` (the resampling step of `standardizeForSTT`) checks its arguments before it converts anything. The target rate and the source rate must be between 1 and 768000 Hz, the channel count between 1 and 64, and the bit depth a multiple of 8 from 8 to 32; anything else is a `Left(ValidationError)` naming the field (`targetRate`, `source.sampleRate`, `source.numChannels` or `source.bitDepth`). An output above 256 MiB (about 46 minutes of 48 kHz mono) is a `ValidationError` on `targetRate` too, checked before anything is allocated: resample a longer recording in pieces. The output has exactly `round(frames * targetRate / sourceRate)` frames, so empty input gives empty output and a trailing partial frame is ignored; a converter that delivers noticeably fewer frames than that is a `ProcessingError`, not a silently padded success. Java Sound's converter delays the signal by a fraction of a millisecond, and the length fit drops the matching tail, so the source's final fraction of a millisecond is not in the output. --- ## Configuration ### Vosk Configuration (not compile-checked) ```scala import org.llm4s.speech.stt.VoskSpeechToText // Use default English model val stt = new VoskSpeechToText() // Use custom model path val stt = new VoskSpeechToText(modelPath = Some("/path/to/vosk-model")) ``` ### Environment Variables ```bash # Vosk Model Path (optional) VOSK_MODEL_PATH=/path/to/vosk-model ``` --- ## File Structure ``` src/main/scala/org/llm4s/speech/ ├── Audio.scala # Core audio data structures ├── stt/ # Speech-to-Text implementations │ ├── SpeechToText.scala # STT trait interface │ ├── VoskSpeechToText.scala # Vosk integration │ └── WhisperSpeechToText.scala # Whisper CLI integration ├── tts/ # Text-to-Speech implementations │ ├── TextToSpeech.scala # TTS trait interface │ └── Tacotron2TextToSpeech.scala # Tacotron2 CLI integration ├── processing/ # Audio preprocessing utilities │ └── AudioPreprocessing.scala # Audio transformation functions ├── io/ # Audio I/O operations │ └── AudioIO.scala # File saving utilities └── util/ # Cross-platform utilities └── PlatformCommands.scala # OS-agnostic command helpers ``` --- ## Cross-Platform Support The `PlatformCommands` utility automatically provides the right commands: | Platform | Echo | File Reader | Directory Listing | |----------|------|-------------|-------------------| | Windows | `cmd /c echo` | `cmd /c type` | `cmd /c dir` | | POSIX | `echo` | `cat` | `ls` | --- ## External Tools ### Whisper - **Installation**: `pip install openai-whisper` - **Usage**: The module integrates with Whisper CLI for transcription - **Models**: Supports various model sizes (tiny, base, small, medium, large) ### Tacotron2 - **Installation**: Requires Tacotron2 CLI tool - **Usage**: The module integrates with Tacotron2 CLI for synthesis - **Features**: Voice customization, language support, audio output --- ## Cloud providers Three cloud TTS clients and two cloud STT clients live in `org.llm4s.speech.tts.provider` and `org.llm4s.speech.stt.provider`. They are selected like chat models, with a `provider/model` string, and built through `SpeechProviderSelector`: | Setting | Values | |---|---| | `SPEECH_TTS_MODEL` | `openai/tts-1`, `openai/tts-1-hd`, `elevenlabs/`, `azure/` (e.g. `azure/en-US-JennyNeural`) | | `SPEECH_TTS_VOICE` | optional voice override (OpenAI default `alloy`) | | `SPEECH_STT_MODEL` | `openai/whisper-1`, `azure/` (e.g. `azure/en-US`) | | `OPENAI_API_KEY` | OpenAI TTS and STT | | `ELEVENLABS_API_KEY` | ElevenLabs TTS (optional `ELEVENLABS_MODEL_ID`, default `eleven_multilingual_v2`) | | `AZURE_SPEECH_KEY`, `AZURE_SPEECH_REGION` | Azure TTS and STT | Only the selected provider's credentials are needed. The same keys exist as HOCON under `llm4s.speech.*` (see this module's `reference.conf`); `OPENAI_SPEECH_BASE_URL`, `ELEVENLABS_BASE_URL`, `AZURE_SPEECH_TTS_BASE_URL` and `AZURE_SPEECH_STT_BASE_URL` override the endpoints. (not compile-checked) ```scala import org.llm4s.speech.SpeechProviderSelector import org.llm4s.speech.tts.TTSOptions import org.llm4s.speech.stt.STTOptions import org.llm4s.speech.AudioInput for { tts <- SpeechProviderSelector.tts() // reads llm4s.speech.tts / SPEECH_TTS_MODEL audio <- tts.synthesize("Hello from LLM4S") stt <- SpeechProviderSelector.stt() // reads llm4s.speech.stt / SPEECH_STT_MODEL text <- stt.transcribe(AudioInput.FileAudio(java.nio.file.Paths.get("hello.wav")), STTOptions()) } yield text.text ``` To pick the model in code instead of `SPEECH_*_MODEL`, use `SpeechConfigLoader.tts("openai/tts-1")` / `.stt("openai/whisper-1")` (credentials still come from config) and `SpeechProviderSelector.getTTSClient` / `getSTTClient`. Behaviour worth knowing: - **Audio is raw PCM.** The TTS clients request raw 24 kHz, 16-bit, mono PCM from each service, so `GeneratedAudio.data` is headerless PCM with an accurate `AudioMeta`, the same shape Tacotron2 produces. Write a playable file with `WavFileGenerator.saveAsWav(audio, path)`. The default `GeneratedAudio.format` is labelled `AudioFormat.WavPcm16` but, as above, the bytes carry no WAV header. - **MP3 is opt-in.** `TTSOptions(outputFormat = AudioFormat.Mp3)` makes the OpenAI, ElevenLabs and Azure clients request the service's MP3 (`response_format=mp3`, `output_format=mp3_44100_128`, `audio-24khz-48kbitrate-mono-mp3`) and return its bytes untouched: no decoding, no resampling. `GeneratedAudio.format` is `AudioFormat.Mp3`, so the PCM helpers (`WavFileGenerator.saveAsWav`, `AudioIO.saveWav` / `saveRawPcm16`, `AudioPreprocessing.standardizeForSTT(audio, rate)`) refuse it with a `ValidationError`; save it with `AudioIO.saveMp3(audio, path)`. `AudioMeta` carries the requested sample rate and channel count as nominal labels (not verified against the service) and `bitDepth = 0`: MP3 has no sample width, so do not compute a duration from the byte count. Tacotron2 produces PCM only and refuses MP3. The default stays PCM. - **OpenAI TTS limits are checked locally**: text over 4096 characters and a `speakingRate` outside 0.25 to 4.0 are a `ValidationError` before any request is sent. Split long text yourself; the clients do not chunk it. - **STT input is WAV.** `BytesAudio` and `StreamAudio` are treated as WAV data, as for Whisper and Vosk. Azure's REST endpoint recognises about 60 seconds of audio per request. - **Errors map like the chat providers**: HTTP 401/403 is `AuthenticationError`, 429 is `RateLimitError` (with `Retry-After`), 400 is `ValidationError`, other statuses are `ServiceError`, and a timeout or refused connection is `TimeoutError` / `NetworkError`. - The clients take an `Llm4sHttpClient`, so tests inject a stub and never touch the network. --- ## Error Handling The module uses `Result[T]` (alias for `Either[LLMError, T]`) for robust error handling: (not compile-checked) ```scala val result: Result[Transcription] = stt.transcribe(audioInput, options) result match { case Right(transcription) => println(s"Transcript: ${transcription.text}") case Left(error) => println(s"Error: ${error.formatted}") } ``` --- ## Testing The module includes comprehensive tests that work cross-platform: ```bash # Run all tests sbt test # Run specific test suites sbt "testOnly org.llm4s.speech.*" # Run the current supported Scala build sbt test ``` The cloud clients are covered without a network by `speech/test` (stubbed HTTP layer, including `CloudSpeechProviderIntegrationSpec`). Live-API smoke suites, one per provider, are in `modules/it` (`org.llm4s.speech.*`, tier `@Cloud`): run them with `sbt testSmoke` and `OPENAI_API_KEY`, `ELEVENLABS_API_KEY`, `AZURE_SPEECH_KEY` / `AZURE_SPEECH_REGION` set. They make billed calls; a suite whose key is missing is skipped, or fails under `LLM4S_IT_STRICT=true`. --- ## See Also - [Examples](/examples/) - Working code examples - [API Reference](/api/llm-client) - Complete API documentation --- Source: https://llm4s.org/guide/builtin-tools # Built-in Tools Ready-made tools for agents: a calculator, the date and time, UUIDs, JSON, files, HTTP, a shell and web search. --- The built-in tools live in the `llm4s-agent-tools` module (see [installation](../getting-started/installation#for-built-in-tools-web-search-http-filesystem-shell)). They depend on `llm4s-core` only, not on the agent runtime, so they work with plain tool calling through a `ToolRegistry` as well as with an `Agent`. Some of them can read your files, call your network or run programs, so decide which ones a model gets before you register anything: [section 6](#6-safety-what-each-tool-can-do) says what each can do. What this page says is checked by tests. The bundle tables, the parameter table, the defaults and the safety section are asserted against the real code by [`BuiltinToolsGuideSpec`](https://github.com/llm4s/llm4s/blob/main/modules/agent-tools/src/test/scala/org/llm4s/toolapi/builtin/BuiltinToolsGuideSpec.scala). The snippets are compiled and run there too, with configuration passed in explicitly where a snippet would read your environment, except the agent snippet, which needs both `llm4s-agent` and `llm4s-agent-tools` and so is run by [`BuiltinToolsGuideAgentSpec`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/test/scala/org/llm4s/samples/toolapi/BuiltinToolsGuideAgentSpec.scala) in `samples`. The last snippet, which refers to a tool of yours, is the one exception. If you change the page, change the specs. ## 1. Pick a bundle `BuiltinTools` builds a set of tools for you. Every method returns a `Result`, because building a tool can fail. | Bundle | Tools it contains | Reach | |--------|-------------------|-------| | `BuiltinTools.coreSafe` | `get_current_datetime`, `calculator`, `generate_uuid`, `json_tool` | None: no files, no network, no processes | | `BuiltinTools.withHttpSafe()` | the core tools, plus `http_request` | Read-only HTTP | | `BuiltinTools.withFilesSafe()` | the above, plus `read_file`, `list_directory`, `file_info` | Read-only HTTP and files | | `BuiltinTools.developmentSafe()` | the above, plus `write_file`, `shell_command` | Files (read and write) and a shell | | `BuiltinTools.customSafe(...)` | the core tools, plus a tool for each configuration you pass | Exactly what you configure | Two things are in no bundle: the web search tools ([section 4](#4-web-search)) and `WeatherTool` ([section 7](#7-things-to-know)). You add them yourself. Start from the smallest bundle that does the job. `customSafe` is the one to use in production, because it forces you to say what each tool may touch: (not compile-checked) ```scala import org.llm4s.toolapi.builtin.BuiltinTools import org.llm4s.toolapi.builtin.filesystem.FileConfig import org.llm4s.toolapi.builtin.http.HttpConfig val tools = BuiltinTools.customSafe( fileConfig = Some(FileConfig(allowedPaths = Some(Seq("/srv/agent-data")))), httpConfig = Some(HttpConfig.restricted(Seq("api.example.com"))) ) // Result[Seq[ToolFunction[_, _]]] ``` ## 2. Register the tools ### With a `ToolRegistry` (not compile-checked) ```scala import org.llm4s.toolapi.{ ToolCallRequest, ToolRegistry } import org.llm4s.toolapi.builtin.BuiltinTools BuiltinTools.coreSafe.foreach { tools => val registry = new ToolRegistry(tools) registry.execute(ToolCallRequest("calculator", ujson.Obj("operation" -> "multiply", "a" -> 6, "b" -> 7))) match { case Right(json) => println(json("result")) // 42.0 case Left(error) => println(error.getFormattedMessage) } } ``` `registry.getOpenAITools()` gives the tool definitions in the format every client takes. `execute` returns `Either[ToolCallError, ujson.Value]`: a refused or failed call is a `Left` you can read, never an exception. For example, dividing by zero comes back as `Left(...)` whose `getFormattedMessage` includes "Division by zero". ### With an `Agent` (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.toolapi.ToolRegistry import org.llm4s.toolapi.builtin.BuiltinTools // `client` is an LLMClient: see Basic Usage for how to create one val result = for { tools <- BuiltinTools.coreSafe agent <- Agent.builder("calculator-agent", client).withTools(new ToolRegistry(tools)).build() state <- agent.run("What is 15% of 850?") } yield state result.map(_.answer) // Right(Some("...")) when the run completed ``` `answer` is `None` when the run did not complete (a guardrail blocked it, or it hit the step limit): read `status` to see why. The runnable version is [`BuiltinToolsAgentExample`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/agent/BuiltinToolsAgentExample.scala). ## 3. The tools The names are what the model sees, and the parameters are what it can send. A parameter in **bold** is required: a call without it comes back as a `Left` naming it. The others have defaults. | Tool | Bundle | Parameters | What it returns | |------|--------|------------|-----------------| | `get_current_datetime` | core | `timezone` (default UTC), `format` (`iso` or `human`: English text; anything else is an error) | `datetime`, `timezone`, `timestamp`, `iso8601`, `components` | | `calculator` | core | **`operation`** (`add`, `subtract`, `multiply`, `divide`, `power`, `sqrt`, `percentage`, `abs`, `min`, `max`, `modulo`), **`a`**, `b` (needed by the two-operand operations) | `expression`, `result`, `formatted` | | `generate_uuid` | core | `count` (1 to 10, default 1), `format` (`standard` or `compact`) | `uuids`: each with `uuid`, `version`, `variant` | | `json_tool` | core | **`operation`** (`parse`, `format`, `query`, `validate`), **`json`**, `path` (for `query`) | `success`, `result`, `formatted` | | `http_request` | http | **`url`**, `method`, `headers`, `body`, `content_type` | `statusCode`, `headers`, `body`, `contentType`, `truncated`, and more | | `read_file` | files | **`path`**, `max_lines`, `encoding` | `path`, `content`, `size`, `lines`, `truncated` | | `list_directory` | files | **`path`**, `max_entries` (at most 100), `include_hidden` | `entries`, `totalFiles`, `totalDirectories`, `truncated` | | `file_info` | files | **`path`** | `exists`, `size`, `sizeHuman`, timestamps, permissions, `isSymlink`, `extension` | | `write_file` | development | **`path`**, **`content`**, `append`, `encoding` | `path`, `bytesWritten`, `created`, `appended` | | `shell_command` | development | **`command`** | `exitCode`, `stdout`, `stderr`, `truncated`, `timedOut` | | `duckduckgo_search` | none | **`search_query`** | `abstract_`, `answer`, `relatedTopics`, and more | | `brave_web_search` | none | **`search_query`** | web results (`brave_image_search`, `brave_video_search` and `brave_news_search` exist too) | | `exa_search` | none | **`query`** | results with `title`, `url`, `text`, `highlights`, and more | `json_tool` queries use dot notation for objects and brackets for arrays: `data.users[0].name`. A path is read to the end: an empty segment (`a..b`), an index that is not a whole number (`a[x]`, `a[-1]`) or an index too large for an `Int` is an error that names the text it stopped at, not a silently shortened query. Every operation refuses a document nested more than 512 levels deep. ## 4. Web search Three search tools are not in any bundle, because each calls a third party and two of them need a key. Their settings are read from `llm4s.tools.*` with `ToolsConfigLoader`, then passed to the tool: (not compile-checked) ```scala import org.llm4s.config.ToolsConfigLoader import org.llm4s.toolapi.ToolRegistry import org.llm4s.toolapi.builtin.BuiltinTools import org.llm4s.toolapi.builtin.search.DuckDuckGoSearchTool val registry = for { config <- ToolsConfigLoader.loadDuckDuckGoSearchTool() search <- DuckDuckGoSearchTool.create(config) core <- BuiltinTools.coreSafe } yield new ToolRegistry(core :+ search) ``` The Brave and Exa tools are built the same way: (not compile-checked) ```scala import org.llm4s.config.ToolsConfigLoader import org.llm4s.toolapi.builtin.search.{ BraveSearchCategory, BraveSearchTool, BraveWebSearchResult, ExaSearchTool } val brave = ToolsConfigLoader .loadBraveSearchTool() .flatMap(config => BraveSearchTool.create[BraveWebSearchResult](config, BraveSearchCategory.Web)) val exa = ToolsConfigLoader.loadExaSearchTool().flatMap(config => ExaSearchTool.create(config)) ``` Brave also has image, video and news categories. These are the keys, as `llm4s-agent-tools` ships them in its `reference.conf` (set them in your `application.conf`, or let the environment variable fill them): | Tool | Key under `llm4s.tools` | Default | Environment variable | |------|-------------------------|---------|----------------------| | DuckDuckGo | `duckduckgo.apiUrl` | `https://api.duckduckgo.com/` | `DUCK_DUCK_GO_SEARCH_API_URL` | | Brave | `brave.apiKey` | none: required | `BRAVE_SEARCH_API_KEY` | | | `brave.apiUrl` | `https://api.search.brave.com/res/v1` | `BRAVE_SEARCH_API_URL` | | | `brave.count` | `5` | `BRAVE_SEARCH_COUNT` | | | `brave.safeSearch` | `moderate` | `BRAVE_SAFE_SEARCH` | | Exa | `exa.apiKey` | none: required | `EXA_API_KEY` | | | `exa.apiUrl` | `https://api.exa.ai` | `EXA_API_URL` | | | `exa.numResults` | `10` | `EXA_NUM_RESULTS` | | | `exa.searchType` | `auto` (or `neural`, `fast`, `deep`) | `EXA_SEARCH_TYPE` | | | `exa.maxCharacters` | `500` | `EXA_MAX_CHARACTERS` | `ExaSearchTool.create` rejects an empty API key when it builds the tool, so a missing key is an error at start-up and not in the middle of a conversation. DuckDuckGo needs no key and is best for definitions, facts and quick lookups, not for general web search. Whatever the model searches for is sent to the search provider. ## 5. Configure the tools Each tool that can reach outside the process takes a configuration. The defaults below are what you get when you do not pass one. ### Files: `FileConfig` and `WriteConfig` | Setting | `FileConfig` (read tools) | `WriteConfig` (`write_file`) | |---------|---------------------------|------------------------------| | `allowedPaths` | `None`: any path outside `blockedPaths` | **required**: no default | | `blockedPaths` | `FileConfig.DefaultBlockedPaths`: the platform's system and credential locations (wins over `allowedPaths`; see below) | not a setting | | `maxFileSize` | 1 MB | 10 MB | | `followSymlinks` | `false` | not a setting | | `allowOverwrite` | not a setting | `false` | | `createDirectories` | not a setting | `true` | (not compile-checked) ```scala import org.llm4s.toolapi.builtin.BuiltinTools import org.llm4s.toolapi.builtin.filesystem.{ FileConfig, WriteConfig } val tools = BuiltinTools.customSafe( fileConfig = Some(FileConfig(allowedPaths = Some(Seq("/srv/agent-data")), maxFileSize = 256 * 1024)), writeConfig = Some(WriteConfig(allowedPaths = Seq("/srv/agent-data/out"))) ) ``` A path is judged by where it really is: symbolic links are resolved, then that real location is compared with each allowed or blocked entry (resolved the same way), one path component at a time. The file tools remove `..` as text first and then open the location they judged. `FileConfig.isPathAllowed`, and the shell tool's path policy, judge the path as given, and an operating system applies a `..` that comes after a link in one of two ways: POSIX (Linux, macOS) follows the link first, so `link/..` is the parent of the link's target, while Windows removes `..` as text first, so `link/..` is the directory holding the link. The check reads the path both ways, on every platform, and allows it only when both locations are allowed. So with `data/l -> data/a/b`, `data/l/../x.txt` is allowed (`data/a/x.txt` or `data/x.txt`), and `data/l/../../x.txt` is refused even on Linux, where it means `data/x.txt`, because on Windows it is `x.txt` beside `data`; spell such a path without the `..` after the link. `/srv/agent-data` covers `/srv/agent-data/notes.txt` but not `/srv/agent-data-secret`, and a link inside an allowed directory that leads outside it is refused, for reading, listing and writing alike. A link that cannot be resolved (a dangling link) is refused. `followSymlinks = false` (the default) additionally refuses a path that is itself a link when reading or listing; it does not decide whether a link may lead out of the allowed area, which is always refused. The tools open the resolved path; a directory swapped for a link between the check and the open is a race this narrows and does not close. A hard link inside an allowed directory to a file elsewhere is not contained: no path check can tell it from the file itself. On macOS `/var` is a link to `/private/var`, so the default blocklist's `/var` also blocks the temporary directories under `/private/var/folders`. The default blocklist, `FileConfig.DefaultBlockedPaths`, depends on the platform the JVM runs on: | Platform | System locations | Under the user's home, besides the common entries | |----------|------------------|----------------------------------------------------| | Linux and other POSIX | `/etc`, `/var`, `/sys`, `/proc`, `/dev`, `/boot`, `/root`, `/run` | `.local/share/keyrings` | | macOS | `/etc`, `/var`, `/sys`, `/proc`, `/dev`, `/private/etc`, `/private/var`, `/Library/Keychains` | `Library/Keychains` | | Windows | `C:\Windows`, `C:\ProgramData\Microsoft\Crypto`, `C:\ProgramData\ssh` | `_netrc`, `NTUSER.DAT`, and under `AppData`: `Roaming\Microsoft\Credentials`, `Local\Microsoft\Credentials`, `Roaming\Microsoft\Protect`, `Roaming\Microsoft\Crypto`, `Roaming\gcloud`, `Roaming\gnupg`, `Roaming\GitHub CLI`, `Roaming\postgresql` | The common entries under the home directory (`user.home`) are `.ssh`, `.gnupg`, `.aws`, `.azure`, `.config/gcloud`, `.kube`, `.docker`, `.config/gh`, `.netrc`, `.git-credentials`, `.pgpass`, `.password-store`, `.npmrc`, `.pypirc`, `.m2/settings.xml`, `.m2/settings-security.xml`, `.ivy2/.credentials` and `.sbt/.credentials`. A blocked entry is matched without regard to case (`/ETC/hosts` is inside `/etc`), as a case-insensitive file system such as the macOS and Windows defaults requires. A Windows installed on a drive other than `C:` is not covered: add its directory, as in `FileConfig(blockedPaths = FileConfig.DefaultBlockedPaths :+ "D:\\Windows")`. A blocklist cannot name every sensitive file, so it is defence in depth: set `allowedPaths` to the directories the agent needs. `developmentSafe(workingDirectory, fileAllowedPaths)` reads only inside `workingDirectory` when you give one, and anywhere outside the blocklist when you do not. It writes inside `fileAllowedPaths` (default `/tmp`) and the working directory, and it **does** allow overwriting. The default blocklist includes `/var`, which on macOS is where the system temporary directory lives (`/var/folders/...`): a file there is refused even if you put its directory in `allowedPaths`. ### HTTP: `HttpConfig` | Setting | Default | |---------|---------| | `allowedMethods` | `GET`, `HEAD` | | `allowedDomains` | `None`: any domain that is not blocked | | `blockedDomains` | `localhost`, `127.0.0.1`, `0.0.0.0`, `::1`, the cloud metadata hosts and `169.254.169.254` | | `blockInternalIPs` | `true` | | `followRedirects` | `false` | | `redirectSafeHeaders` | `Accept`, `Accept-Language`, `Accept-Encoding`, `User-Agent`, `Content-Type` (only with a re-sent body): the only caller-set headers a redirect carries to another origin | | `timeout` | 30 seconds, for the whole call: connecting, every redirect hop and reading the body | | `maxResponseSize` | 10 MB: at most this many bytes of the body are read; the rest is never read, and `truncated` is `true` | `HttpConfig.restricted(Seq("api.example.com"))` allows only those domains. `HttpConfig.withWriteMethods()` adds `POST`, `PUT`, `DELETE` and the rest, and `HttpConfig.unsafe` also turns the address checks off: use that only in a sandbox. ### Shell: `ShellConfig` | Setting | Default | |---------|---------| | `allowedCommands` | none: an empty list allows nothing | | `workingDirectory` | the process's own | | `timeout` | 30 seconds | | `maxOutputSize` | 100,000 characters | | `environment` | none: extra variables, set on top of the environment the command receives | | `inheritedEnvironment` | `PATH`, `LANG`, `LC_ALL`, `TERM` and `SystemRoot` (those that exist); `development()` passes the whole environment | | `pathPolicy` | none: the file-like arguments of a command are not checked | `ShellConfig.readOnly()` allows `ls`, `cat`, `head`, `tail`, `pwd`, `echo`, `wc`, `date`, `whoami`, `which` and `file`. `ShellConfig.readOnlyWithin(policy)` is the same list with the working directory and every file-like argument held to a `FileConfig` by its `isPathAllowed` (the rule above, with a `..` after a link read both the POSIX and the Windows way). `ShellConfig.development()` adds `git`, `sbt`, `make`, `npm`, `grep`, `find`, `cp`, `mv`, `rm` and more: read the next section before you use it. ## 6. Safety: what each tool can do A tool call is a request from a model, and a model can be wrong, confused or steered by text it read. Pick the bundle by what you would be willing to let it do unsupervised. | Bundle | A model given it can | |--------|----------------------| | `coreSafe` | compute, read the clock, make UUIDs and parse the JSON it is given. Nothing leaves the process. | | `withHttpSafe()` | all of the above, and fetch pages from the public internet with `GET` and `HEAD`: your server's IP address is the one the remote site sees. | | `withFilesSafe()` | all of the above, and read files: by default any the process can read outside the blocklist (`/etc`, `/var`, ...), so set `allowedPaths`. | | `developmentSafe()` | all of the above, and write files, and run any program on the allowlist, which for `development()` is most of a developer's toolbox. | What the controls do, and where they stop: - **HTTP** refuses methods outside `allowedMethods`, a scheme other than `http` and `https`, any domain outside `allowedDomains`, `localhost`, loopback, the cloud metadata addresses, and private ranges such as `10.x`, `172.16.x` and `192.168.x` (and their IPv6 counterparts, such as unique-local `fc00::/7` and IPv6 forms that carry a private IPv4 address, and every other range the IANA special-purpose registries mark as not globally reachable, such as `240.0.0.0/4`, `192.0.0.0/24` and IPv6 outside `2000::/3`), all before sending a request. Redirects are not followed unless you turn that on; when they are, each hop is checked again, and from the first hop that leaves the original scheme, host and port (so a downgrade from `https` to `http` counts), and on every hop after it, only the headers on `redirectSafeHeaders` go with the request: every other header the model set, such as `X-Api-Key` or `X-Auth-Token`, is dropped, and a credential header (`Authorization`, `Cookie`, a name that redaction treats as sensitive, plurals such as `X-Api-Keys` included, or one ending in `token` or `key`) is dropped even if you list it. A hop that drops the body (a `301` or `302` after a `POST`, `PUT`, `PATCH` or `DELETE`), on either origin, sends no `Content-Type`, neither a caller-set header nor the tool's own `content_type`. An IPv6 address inside a global prefix is not judged by the IPv4 address its interface identifier may embed (ISATAP-style `...:5efe:a.b.c.d`), so such an address is allowed whatever IPv4 address it names. `timeout` bounds the whole call, so a server that answers a byte at a time cannot hold the tool past it. A `TIMEOUT` releases the caller, not the request: one already sent may still be delivered and acted on (a `POST` is not rolled back), and a DNS lookup cannot be interrupted (`InetAddress.getAllByName` waits for the system resolver), so the tool's worker thread may outlive the deadline by up to the resolver's own timeout, typically a few seconds per attempt as `/etc/resolv.conf` or the OS sets it ([#1734](https://github.com/llm4s/llm4s/issues/1734)). The address check resolves the host name, and the connection then resolves it again, so a domain whose DNS answer changes in between (DNS rebinding) can pass the check with a public address and connect to a private one. The request still goes out from your network, so do not give it to a model that handles untrusted text next to credentials or internal services the server can reach; where that matters, also block private ranges at the network level, for example with an egress proxy or firewall. - **Files**: `allowedPaths` and `blockedPaths` judge the real location of a path (links resolved, compared one component at a time), so a link cannot lead out of an allowed directory and a sibling that shares a name prefix is not inside it. Treat them as a containment check on the file tools, not as a sandbox: the check and the open are two steps, which this narrows and does not close, and the shell tool is a separate matter (below). Allow one directory made for the agent, and when the files matter, run the process as a user that cannot read anything else, or in a container. - **Shell**: the command is split into words and started directly, **without a shell**. `&&`, `;`, `|`, `>` and `$VAR` reach the program as ordinary text and are not interpreted, and only the first word is checked against `allowedCommands`. That stops a command from chaining into another one. It does not limit what an allowed program does: - `readOnly()` is an allowlist of program names, not read-only execution. Its programs only read in ordinary use, but most options are passed through unchecked: `date -s` sets the clock when the process is allowed to. The options that write a file, run a program or read one the command does not name are refused (`file -C`, `-m`, `-M` and `-f`, `date -f` and `-r`, `wc --files0-from`, and, for an allowlist that adds `sort`, `sort -o`, `--output`, `-T`, `--temporary-directory`, `--compress-program` and `--files0-from`, plus on Windows, where `sort` may be `sort.exe`, `sort -t` and the `/O` and `/T` switches) in every spelling: anywhere in a cluster of short options, with the value attached or not (`-bC`, `-Mmagic`, `-roout`), but not inside the value of `sort -t` (`sort -to` sets the separator to `o`), under any abbreviation of the long form (`date --fil`), with or without `=value`, after a `--`, which an option taking an argument can consume, and whatever the case of the program's name or a Windows `.exe` (`FILE -C`, `file.exe -C`) ([#1723](https://github.com/llm4s/llm4s/issues/1723)). `cat`, `head` and `tail` can read any file the process can read, **so the file settings above do not apply to the shell** unless you use `ShellConfig.readOnlyWithin(policy)`, which holds the working directory and each file-like argument to that rule, judged as the program will hand it to the OS (a `..` after a link is read both as POSIX applies it, at the link target's parent, and as Windows does, at the directory holding the link, and both must be allowed). A value attached to a flag is checked the same way, since the program may open it: the value after `=` of a long option (`grep --file=x`) and every tail of a short-option cluster (`grep -fx`, `grep -ifx`), so a link out of the allowed directory, a blocked file or `..` cannot be passed that way ([#1723](https://github.com/llm4s/llm4s/issues/1723)). Which options take a value is not modelled, so text that happens to name such a file (`grep -e..`) is refused too. A value that is one plain path component (no `/` or `\`, not `.` or `..`, nothing Windows reads specially such as `:`, a trailing `.` or space, or a device name) costs one lookup, whether anything is in the working directory under that name: if nothing is, it cannot be a link, and the policy is applied to its spelling with no further lookup, which is exactly the full check's verdict; if something is, it is resolved in full. No limit on the length of a file name is assumed. The cost of the checks is bounded per command: an argument longer than 4096 characters is refused, each distinct path is checked once, and a command whose checks would take more than 20000 file-system lookups (one per plain component, two per component of a path resolved in full) is refused as too costly to check, rather than holding the agent for minutes before the command starts (the shell `timeout` covers only the command's run). A hard link to a file outside passes, as it does for the file tools. A command that walks directories itself (`ls -R`, `grep -r`, `find`) is checked only at the path it starts from. - On Windows, a character a program may receive as a different ASCII character is refused, with or without a path policy ([#1766](https://github.com/llm4s/llm4s/issues/1766)). A program that reads its command line in an ANSI code page gets each argument converted with "best fit", so `../../secret` (U+FF0F) reaches it as `../../secret`, `¥` and `₩` as `\` on the Japanese and Korean code pages, and `-o` as `-o`, past checks that read the argument as one plain name or no option. An argument, the working directory and an `environment` value are refused when they hold a character a common ANSI code page (874, 932, 936, 949, 950, 1250 to 1258) best-fits to an ASCII character other than a letter or digit, any best-fitted character in an argument starting with `-` or `/`, or a character the host's own ANSI code page cannot hold. Accented letters such as `é` stay allowed in an operand when that code page holds them. POSIX hosts are unchanged. - On a Windows host whose ANSI code page is double-byte (932, 936, 950), an argument holding a character whose second byte is 0x5C, the byte of `\` (`表` and `十` on 932, `功` on 950), is refused when the JDK puts it in quotes ([#1807](https://github.com/llm4s/llm4s/issues/1807)): `a 表` reaches the command line as `"a 表"`, whose bytes end `5C 22`, and a program that splits its command line byte by byte reads `\"` as a literal quote and splits the arguments after it differently. The JDK quotes an argument holding a space or whitespace character, `"`, `<`, `>`, `&`, `|` or `^`; `表.txt` stays allowed. - `development()` is not a sandbox: `sbt`, `make`, `npm`, `git`, `find` and `env` can run arbitrary programs, so a model given it can do anything the process can. - The started program receives a scrubbed environment: only `PATH`, `LANG`, `LC_ALL`, `TERM` and `SystemRoot` (those that exist) plus whatever you put in `environment`, so provider API keys in your process's environment do not reach it. `development()` passes the whole environment through, because build tools need it. - **Search** sends the query text to the search provider. A reasonable starting point for an agent that answers questions about some documents is `customSafe` with `fileConfig` set to one dedicated directory, no `writeConfig`, no `shellConfig`, and `httpConfig` set to the domains it needs. Add the shell only for a developer's own machine. ## 7. Things to know - **Failures are values.** A refused call returns a `Left`. The message names the reason: for example `Access denied: path '...' is not allowed`, `HTTP method 'POST' is not allowed. Allowed: GET, HEAD`, `SSRF_BLOCKED: domain '...' is not allowed` or `Command 'rm' is not allowed`. The registry hands the message back to the model, so it can try something else. - **`WeatherTool` is a demo.** It lives in `org.llm4s.toolapi.tools`, returns the same fixed reading (sunny, 22.5) for every location and calls no weather service. Use it to see tool calling work, not for weather. - **The shell lists are POSIX programs.** `readOnly()` and `development()` name programs such as `ls` and `cat`, so on Windows give `ShellConfig` a list of your own. - **A call with a missing parameter is a `Left`.** For example `calculator` without `a` comes back as an error that names the parameter, and nothing runs. ## 8. Your own tools The built-in tools are built with `ToolBuilder`, the same way you build yours, and they register in the same `ToolRegistry`. Build your tool, then add it to the sequence: (not compile-checked) ```scala // myTool: a ToolFunction you built with ToolBuilder val registry = BuiltinTools.coreSafe.map(core => new ToolRegistry(core :+ myTool)) ``` [Build Your Own Tool](custom-tools) walks through writing one: the `Schema` DSL, reading arguments, returning errors and testing it. See [Agents](agents/#agent-with-tools) for how a tool is used in a run, and [Error Handling](error-handling) for working with the `Result` these methods return. ## Related - [Agents](agents/): running tools in an agent loop, with guardrails - [Error Handling](error-handling): `Result[A]` and `LLMError` - [`BuiltinToolsExample`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/toolapi/BuiltinToolsExample.scala): every bundle in a runnable sample --- Source: https://llm4s.org/guide/context-window-pruning # Context Window Pruning Strategies Complete guide to LLM4S context window management and pruning strategies. ## Overview As conversations grow, token counts increase, leading to: - **Higher costs** - Most LLM providers charge per token - **Slower responses** - Longer context = slower processing - **Context window limits** - Models have maximum input token limits LLM4S provides context window management via `ContextWindowConfig` and `ContextWindowMiddleware`, with multiple pruning strategies to keep conversations within budget while preserving important context. The middleware trims what is sent to the model on each call; the thread keeps its full history. Two rules hold for every strategy: the current turn (the latest user message onward) is never pruned, so the request always starts with a user message and may exceed the budget; and a tool call is never separated from its result. The agent's system prompt is outside the budget. ## Configuration Basics (not compile-checked) ```scala import org.llm4s.agent.{ Agent, ContextWindowConfig, PruningStrategy } import org.llm4s.agent.graph.middleware.ContextWindowMiddleware val contextConfig = ContextWindowConfig( maxTokens = Some(4096), // Keep max 4K tokens maxMessages = Some(20), // OR max 20 messages preserveSystemMessage = true, // Always keep system prompt minRecentTurns = 3, // Keep last 3 user/assistant pairs pruningStrategy = PruningStrategy.OldestFirst // How to prune ) val agent = Agent.builder("assistant", client) .withMiddleware(new ContextWindowMiddleware(contextConfig)) .build() agent.flatMap(_.runMultiTurn("...", Seq(...))) ``` ## Pruning Strategies ### 1. OldestFirst (Recommended for most use cases) **How it works:** Removes the oldest messages first while preserving the system message and most recent turns. **Best for:** - ✅ General-purpose multi-turn conversations - ✅ FAQ-bot with rolling history - ✅ Customer support chats - ✅ When you want to keep latest context fresh **Cost:** - 🟢 Good - Removes old/repetitive context first - 🟢 Predictable token savings **Quality:** - 🟡 Medium - May lose context from early turns - ✅ Recent context stays intact **Example:** (not compile-checked) ```scala val config = ContextWindowConfig( maxMessages = Some(15), pruningStrategy = PruningStrategy.OldestFirst ) // Conversation with 30 messages → last 15 messages kept // System message is always preserved ``` **Trade-offs:** | Aspect | Rating | Notes | |--------|--------|-------| | Token efficiency | ⭐⭐⭐⭐ | Removes metadata/greetings first | | Context continuity | ⭐⭐⭐ | Recent context preserved | | Implementation | ⭐⭐⭐⭐⭐ | Simple FIFO removal | | Use case fit | Broad | Works for most scenarios | --- ### 2. MiddleOut (For complex reasoning) **How it works:** Keeps the start (system + initial setup) and end (recent exchanges), removes middle messages. **Best for:** - ✅ Complex problem-solving requiring both initial context and recent progress - ✅ Long code reviews or technical documents - ✅ When you need to preserve both "setup" and "current state" - ✅ Scientific or analytical conversations **Cost:** - 🟡 Medium - Loses middle context but keeps important bookends - 🟡 Less predictable than OldestFirst **Quality:** - 🟡 Medium - Loses intermediate steps - ✅ Good - Keeps initial and recent context **Example:** (not compile-checked) ```scala val config = ContextWindowConfig( maxMessages = Some(20), pruningStrategy = PruningStrategy.MiddleOut ) // Conversation: // [System] [Initial Context] [Step 1] [Step 2] ... [Step N] [Recent Q&A] // ↑ ↑ // These middle steps are pruned; keeps ends ``` **Trade-offs:** | Aspect | Rating | Notes | |--------|--------|-------| | Token efficiency | ⭐⭐⭐ | Medium removal | | Context continuity | ⭐⭐⭐⭐ | Preserves bookends | | Implementation | ⭐⭐⭐⭐ | Straightforward middle removal | | Use case fit | Specialized | Good for reasoning chains | --- ### 3. RecentTurnsOnly (For strict turn-based systems) **How it works:** Keeps only the last N complete conversation turns (user message + assistant response + tool calls). **Best for:** - ✅ Turn-based games or dialogues - ✅ Systems that value latest N interactions equally - ✅ When conversation structure matters more than time - ✅ Predictable conversation patterns **Cost:** - 🟢 Good - Focused token reduction - ✅ Very predictable **Quality:** - 🟡 Medium - Only recent exchanges (may need earlier context) - ✅ Good - Full recent turns preserved **Example:** (not compile-checked) ```scala val config = ContextWindowConfig( pruningStrategy = PruningStrategy.RecentTurnsOnly(5) ) // Keeps exactly the last 5 user/assistant turn pairs // A "turn" = [User message] → [Assistant response] + optional [Tool messages] // Turn 1: User asks → Assistant responds (+ tool calls) // Turn 2: User asks → Assistant responds (+ tool calls) // ... // Turn 5: User asks → Assistant responds (+ tool calls) // Turns 1-N: Pruned ``` **Trade-offs:** | Aspect | Rating | Notes | |--------|--------|-------| | Token efficiency | ⭐⭐⭐⭐ | Removes entire turn groups | | Context continuity | ⭐⭐ | May lose early turns | | Implementation | ⭐⭐⭐⭐ | Turn-based filtering | | Use case fit | Specific | Best for turn-based systems | --- ### 4. Custom (Maximum flexibility) **How it works:** You provide a pure function that decides which messages to keep. **Best for:** - ✅ Domain-specific pruning logic - ✅ Marketing messages removal - ✅ Metadata-first removal - ✅ Complex importance scoring - ✅ Hybrid strategies **Cost:** - 🟢 Excellent - You decide what's expensive - ⭐ Depends on your logic **Quality:** - 🟢 Excellent - You decide what matters - ⭐ Depends on your logic **Example:** (not compile-checked) ```scala // Remove verbose debug messages, keep concise ones val pruneDebugMessages: Seq[Message] => Seq[Message] = { messages => messages.filter { msg => !msg.content.toLowerCase.contains("[debug]") || msg.content.length < 100 // Keep short debug messages } } val config = ContextWindowConfig( maxMessages = Some(50), pruningStrategy = PruningStrategy.Custom(pruneDebugMessages) ) ``` **Advanced Example (importance scoring):** (not compile-checked) ```scala val scoreImportance: Seq[Message] => Seq[Message] = { messages => messages.map { msg => val score = msg.role match { case MessageRole.System => 100 // Always keep case MessageRole.User => 80 // Keep user messages case MessageRole.Assistant => 70 // Keep responses case MessageRole.Tool => 40 // Lower priority case _ => 0 } (msg, score) } .sortBy(_._2) // Sort by importance .dropWhile(_ => tokenCount > limit) // Remove until under limit .map(_._1) } ``` **Trade-offs:** | Aspect | Rating | Notes | |--------|--------|-------| | Token efficiency | ⭐⭐⭐⭐⭐ | Complete control | | Context continuity | ⭐⭐⭐⭐⭐ | Complete control | | Implementation | ⭐⭐ | Requires custom code | | Use case fit | Very specific | Best for special requirements | --- ### 5. AdaptiveWindowing (Intelligent auto-tuning) **How it works:** Automatically calculates the optimal context window based on: - Model's context size - Model tier (small, medium, large, extra-large) - Token pricing (input vs output cost) - Cost sensitivity preference **Best for:** - ✅ Multi-model deployments (switch models without config changes) - ✅ Cost-conscious production systems - ✅ Quality-sensitive applications - ✅ Hybrid cost/quality optimization - ✅ When you want "set it and forget it" **Cost:** - 🟢 Excellent - Optimizes based on actual pricing - ✅ Adapts to model changes **Quality:** - 🟢 Good - Uses 70-80% of context window - ✅ Leaves room for model reasoning **Example:** (not compile-checked) ```scala import org.llm4s.model.ModelMetadata import org.llm4s.agent.PruningStrategy // From ModelMetadata (automatically available) val strategy = PruningStrategy.AdaptiveWindowing( contextWindowSize = 128_000, // gpt-4o context inputCostPerToken = Some(0.000005), // $5 per 1M input tokens outputCostPerToken = Some(0.000015), // $15 per 1M output tokens preserveMinTurns = 3, costSensitivity = 0.5 // Balanced (0=quality, 1=cost) ) val config = ContextWindowConfig( pruningStrategy = strategy ) println(strategy.calculateOptimalWindow) // Output: 89,600 tokens (70% * 128K) println(strategy.explanation) // "AdaptiveWindowing: using 89,600 tokens (70% of 128K large model)" ``` **Model tier adjustments:** | Model Size | Context | Multiplier | Adjusted Window | Use Case | |-----------|---------|-----------|-----------------|----------| | Small | 8K | 60% | 4.8K | Budget-conscious | | Medium | 100K | 70% | 70K | Balanced | | Large | 200K | 75% | 150K | Quality-focused | | Extra Large | 1M+ | 80% | 800K+ | Complex reasoning | **Cost sensitivity examples:** (not compile-checked) ```scala // 🟡 Balanced (default) // Sensitivity = 0.5 // Minimizes cost moderately while preserving quality val balanced = PruningStrategy.AdaptiveWindowing( contextWindowSize = 128_000, inputCostPerToken = Some(0.000005), outputCostPerToken = Some(0.000015), costSensitivity = 0.5 ) // 🟢 Quality-focused // Sensitivity = 0.1 // Use most of the available context, cost matters less val qualityFocused = PruningStrategy.AdaptiveWindowing( contextWindowSize = 128_000, costSensitivity = 0.1 // Low cost sensitivity ) // 💰 Cost-optimized // Sensitivity = 0.9 // Aggressively minimize token usage val costOptimized = PruningStrategy.AdaptiveWindowing( contextWindowSize = 128_000, costSensitivity = 0.9 // High cost sensitivity ) ``` **Trade-offs:** | Aspect | Rating | Notes | |--------|--------|-------| | Token efficiency | ⭐⭐⭐⭐ | Auto-optimized | | Context continuity | ⭐⭐⭐⭐ | Preserves minimum turns | | Implementation | ⭐⭐⭐⭐⭐ | Recommended for production | | Use case fit | Universal | Works everywhere | --- ## Strategy Comparison Matrix | Strategy | Pros | Cons | Cost | Quality | When to Use | |----------|------|------|------|---------|------------| | **OldestFirst** | Simple, predictable | May lose early context | ⭐⭐⭐⭐ | ⭐⭐⭐ | Default choice | | **MiddleOut** | Preserves bookends | Loses intermediate steps | ⭐⭐⭐ | ⭐⭐⭐⭐ | Complex reasoning | | **RecentTurnsOnly** | Consistent turn count | Loses older turns | ⭐⭐⭐⭐ | ⭐⭐ | Turn-based systems | | **Custom** | Maximum flexibility | Requires implementation | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Special needs | | **AdaptiveWindowing** | Auto-optimized, scales | Needs model metadata | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | Production systems | --- ## Configuration Patterns ### Pattern 1: Token-based Limiting (Recommended) (not compile-checked) ```scala val config = ContextWindowConfig( maxTokens = Some(4096), pruningStrategy = PruningStrategy.OldestFirst ) // More precise - based on actual token usage // Handles variable-length messages correctly ``` ### Pattern 2: Message-based Limiting (Simpler) (not compile-checked) ```scala val config = ContextWindowConfig( maxMessages = Some(20), pruningStrategy = PruningStrategy.OldestFirst ) // Simpler to understand // Less precise but easier to reason about ``` ### Pattern 3: Hybrid (Both limits) (not compile-checked) ```scala val config = ContextWindowConfig( maxTokens = Some(4096), maxMessages = Some(50), pruningStrategy = PruningStrategy.OldestFirst ) // Enforces BOTH limits // Prunes when either limit exceeded ``` ### Pattern 4: Preserve Minimum Context (not compile-checked) ```scala val config = ContextWindowConfig( maxMessages = Some(15), preserveSystemMessage = true, minRecentTurns = 3, // Always keep last 3 turns pruningStrategy = PruningStrategy.RecentTurnsOnly(5) ) // Guarantees minimum context is always available // Even if other limits would be exceeded ``` ### Pattern 5: Adaptive Mode (Multi-model) (not compile-checked) ```scala val strategy = if (isExpensiveModel) { PruningStrategy.AdaptiveWindowing( contextWindowSize = modelSize, inputCostPerToken = Some(0.000010), costSensitivity = 0.8 // Aggressive cost optimization ) } else { PruningStrategy.AdaptiveWindowing( contextWindowSize = modelSize, inputCostPerToken = Some(0.000001), costSensitivity = 0.3 // More quality-focused ) } val config = ContextWindowConfig( pruningStrategy = strategy ) ``` --- ## Cost Analysis ### Calculate conversation cost (not compile-checked) ```scala val tokenCounter = ConversationTokenCounter.forModel("gpt-4o").get val conversationTokens = tokenCounter.countConversation(conversation) // Cost calculation val inputCostPerToken = 0.000005 // $5 per 1M tokens val totalCost = conversationTokens * inputCostPerToken println(s"Conversation: $conversationTokens tokens = \$$totalCost") ``` ### Compare strategies (not compile-checked) ```scala // Without pruning: 10,000 tokens = $0.05 // With OldestFirst (keeping 50 messages): ~3,000 tokens = $0.015 (70% savings) // With AdaptiveWindowing: ~4,096 tokens = $0.020 (60% savings) // For 100 conversations per day: // Without: $5.00/day // With pruning: $1.50-2.00/day (savings: $3-3.50/day) ``` --- ## Best Practices ### ✅ DO 1. **Use token-based limits** - More accurate for variable-length conversations (not compile-checked) ```scala maxTokens = Some(4096) // Better than maxMessages ``` 2. **Preserve system message** - Keeps instructions intact (not compile-checked) ```scala preserveSystemMessage = true // Default, always set this ``` 3. **Use AdaptiveWindowing for production** - Handles model changes automatically (not compile-checked) ```scala PruningStrategy.AdaptiveWindowing(contextWindowSize, costPerToken) ``` 4. **Monitor pruning events** - Log when and what gets removed (not compile-checked) ```scala // See next section on observability ``` 5. **Test with real conversations** - Benchmark different strategies ### ❌ DON'T 1. **Set maxTokens too low** - Breaks context coherence (not compile-checked) ```scala maxTokens = Some(256) // Too small! ``` 2. **Forget about output budget** - Leave room for model responses (not compile-checked) ```scala // If context is 100K, use ~70K for input (leave 30K for output) maxTokens = Some(70_000) ``` 3. **Use token limits without a tokenizer** - Falls back to word count estimation (not compile-checked) ```scala // Integrate ConversationTokenCounter for accuracy ``` 4. **Hard-code window sizes** - Use AdaptiveWindowing instead (not compile-checked) ```scala // Bad: Fixed 4K window works only for specific models // Good: AdaptiveWindowing scales with model changes ``` --- ## Observability & Monitoring ### Inspect pruning results Pruning happens in `ContextWindowMiddleware`, on each model call, and never changes the thread. The thread's `AgentResult.messages` is the full history; to see what was sent, wrap the model call with a small middleware of your own, placed after the context window middleware with `runsAfter`: (not compile-checked) ```scala import org.llm4s.agent.graph.middleware.{ AgentMiddleware, MiddlewareId, ModelContext, ModelRequest, ModelResponse } class LogSentMessages(logger: Logger) extends AgentMiddleware { val id = MiddlewareId("log-sent-messages") override def runsAfter = Set(MiddlewareId("context-window")) override def wrapModelCall(request: ModelRequest, context: ModelContext)( next: ModelRequest => Result[ModelResponse] ): Result[ModelResponse] = { logger.info(s"Sending ${request.messages.length} messages") next(request) } } ``` ### Log pruning performance `ContextWindowMiddleware` takes an optional token counter (`new ContextWindowMiddleware(config, tokenCounter)`), and `AgentResult.usage` reports the tokens the provider actually billed across the thread, which is the number to compare with and without pruning: (not compile-checked) ```scala logger.info(s"Thread usage: ${result.usage}") ``` --- ## Troubleshooting ### Issue: Conversation keeps growing despite pruning **Cause:** the agent was built without a `ContextWindowMiddleware` **Solution:** (not compile-checked) ```scala // ❌ Wrong: pruning not enabled Agent.builder("assistant", client).build() // ✅ Right: pruning enabled Agent.builder("assistant", client) .withMiddleware(new ContextWindowMiddleware(config)) // Must add it! .build() ``` ### Issue: Important context is being removed **Cause:** Too aggressive pruning strategy **Solution:** (not compile-checked) ```scala // Increase limits val config = ContextWindowConfig( maxTokens = Some(8192), // Was 4096 pruningStrategy = PruningStrategy.MiddleOut // Preserves bookends ) // Or use Custom strategy to protect important messages ``` ### Issue: Conversations are too short **Cause:** minRecentTurns or preserveSystemMessage settings **Solution:** (not compile-checked) ```scala val config = ContextWindowConfig( maxTokens = Some(8192), minRecentTurns = 1, // Fewer forced turns preserveSystemMessage = false, // Allow system to be pruned if needed pruningStrategy = PruningStrategy.OldestFirst ) ``` --- ## Advanced Examples ### Example 1: Cost-conscious chatbot (not compile-checked) ```scala val config = ContextWindowConfig( pruningStrategy = PruningStrategy.AdaptiveWindowing( contextWindowSize = 8000, // Smaller model inputCostPerToken = Some(0.00001), // Expensive outputCostPerToken = Some(0.00003), costSensitivity = 0.9 // Minimize cost ) ) ``` ### Example 2: Research assistant (preserve all context) (not compile-checked) ```scala val config = ContextWindowConfig( maxTokens = Some(200_000), // Large window pruningStrategy = PruningStrategy.MiddleOut, // Keep setup + recent preserveSystemMessage = true ) ``` ### Example 3: Code review assistant (not compile-checked) ```scala val config = ContextWindowConfig( pruningStrategy = PruningStrategy.Custom { messages => messages.filter { msg => // Keep code snippets and review comments msg.role == MessageRole.System || // Keep instructions msg.content.contains("```") || // Keep code blocks msg.content.contains("review") || // Keep review comments messages.indexOf(msg) >= messages.length - 10 // Keep recent } } ) ``` ### Example 4: Turn-based game (not compile-checked) ```scala val config = ContextWindowConfig( pruningStrategy = PruningStrategy.RecentTurnsOnly(10), // Last 10 turns preserveSystemMessage = true, minRecentTurns = 1 // Always keep last turn ) ``` --- ## Summary | Need | Strategy | Reason | |------|----------|--------| | I just want a working solution | **OldestFirst** | Simplest, most reliable | | I'm building a production system | **AdaptiveWindowing** | Auto-scales with models | | I have complex reasoning | **MiddleOut** | Preserves bookends | | I need precise control | **Custom** | Maximum flexibility | | I have turn-based logic | **RecentTurnsOnly** | Natural for turns | Choose **AdaptiveWindowing** for production systems that work across multiple models. Choose **OldestFirst** for everything else. --- Source: https://llm4s.org/guide/agents/ # Agent Framework Build sophisticated AI agents with tools, guardrails, memory, and multi-agent coordination. --- ## Overview The LLM4S Agent Framework provides a production-ready foundation for building LLM-powered agents with: - **Tool Calling** - Type-safe tools with automatic schema generation - **Guardrails** - Input/output validation for safety and quality - **Memory** - Short and long-term context with semantic search - **Handoffs** - Agent-to-agent delegation for specialist routing - **Streaming** - Real-time events for responsive UIs (`agent.stream`, [streaming guide](streaming)) - **Graphs** - Typed multi-agent workflows on `GraphBuilder`: parallel nodes, joins and checkpoints ([recipe](../../examples/cookbook/multi-agent-graph.html)) ## Quick Start The agent runtime is the `llm4s-agent` module, alongside `llm4s-core` and a provider module: (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent" % llm4sVersion ``` ### Basic Agent An agent is built once - tools, guardrails, handoffs and middleware belong to it - and run by thread. (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.agent.Agent import org.llm4s.model.ModelRegistryService // Create an agent and run a query val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) agent <- Agent.builder("assistant", client).build() result <- agent.run("What is the capital of France?") } yield result result match { case Right(r) => println(r.answer.getOrElse(s"Run ended: ${r.status}")) case Left(error) => println(s"Error: $error") } ``` `Agent.builder(id, client)` takes the agent's id (letters, digits, `_` and `-`, up to 52) and an `LLMClient`. `build()` returns a `Result[Agent]` and refuses an invalid agent: clashing tool names, a bad or duplicate handoff id, or `withMaxSteps` below 1. ### Agent with Tools (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService import org.llm4s.toolapi.{ SafeParameterExtractor, Schema, ToolBuilder, ToolRegistry } // Define a tool def getWeather(params: SafeParameterExtractor): Either[String, String] = params.getString("location").map(location => s"The weather in $location is sunny, 72F") val weatherSchema = Schema .`object`[Map[String, Any]]("Weather query") .withProperty(Schema.property("location", Schema.string("The city to get the weather for"))) // Give the agent its tools when you build it val result = for { weatherTool <- ToolBuilder[Map[String, Any], String]( "get_weather", "Get current weather for a location", weatherSchema ).withHandler(getWeather).buildSafe() providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) agent <- Agent.builder("assistant", client) .withTools(new ToolRegistry(Seq(weatherTool))) .build() result <- agent.run("What's the weather in Paris?") } yield result ``` `withTools` takes a `ToolRegistry` (an `MCPToolRegistry` too) or a `ToolSet` of `AgentTool`s. ### Multi-Turn Conversations A conversation is carried by its thread. `continueConversation` runs the next turn on the thread of a previous result: (not compile-checked) ```scala val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) agent <- Agent.builder("assistant", client).build() // First turn result1 <- agent.run("Tell me about Scala") // Follow-up (preserves context) result2 <- agent.continueConversation(result1, "How does it compare to Java?") // Another follow-up result3 <- agent.continueConversation(result2, "What about performance?") } yield result3 ``` To name the thread yourself, use `agent.run(threadId, query)`; `agent.runMultiTurn(first, followUps)` runs several turns on one thread and stops at the first result that is not `Completed`. A `Left` carries no thread id, so when a turn of `run(query)` or `runMultiTurn` fails or is cancelled, its thread is forgotten once the turn has ended; name the thread to recover such a turn. ### Handling the Result `run` returns `Result[AgentResult]`. `Left` is a failure: a provider, tool or middleware error is a `GraphError`, and a thread that is busy or still has pending work is refused. A run that ended in a defined way is `Right`, with a status: (not compile-checked) ```scala result.map { r => r.status match { case AgentStatus.Completed(answer) => println(answer) case AgentStatus.Blocked(guardrail, why) => println(s"Blocked by $guardrail: $why") case AgentStatus.StepLimitReached => println("Hit the step limit") case AgentStatus.TokenLimitReached => println("Hit the run's token budget") case AgentStatus.Suspended(approvals, qs, asked, held) => println("Waiting for a person") } } ``` `AgentResult` also carries `threadId`, `runId`, `activeAgent`, `messages` (the thread's full history, never a system prompt) and `usage`. ### A Typed Answer `withOutput(schema)` gives the agent a response format: a `SchemaDefinition[O]`, with a uPickle `ReadWriter[O]` in scope. Write the schema by hand, or derive it with `llm4s-schema-derivation`: `withOutput(SchemaOf[Invoice].definition)`. The system prompt states the schema on every model call, and a call that offers no tools also carries it as its `responseFormat`. A call that offers tools carries no format, because several providers cannot combine one with tool calls. `decodeOutput[O]` reads the answer: (not compile-checked) ```scala case class Invoice(customer: String, total: Double) derives ReadWriter val invoiceSchema: ObjectSchema[Invoice] = ??? // or SchemaOf[Invoice].definition for { agent <- Agent.builder("extractor", client).withOutput(invoiceSchema).build() result <- agent.run("Bill Acme 12.50") invoice <- result.decodeOutput[Invoice] } yield invoice ``` A final answer must be JSON that matches the schema and reads as `O`; it is stored as that JSON alone. An invalid answer is sent back to the model once, with its problems, in the same step; neither is stored. A second invalid answer ends the turn with `Left(GraphError.OutputInvalid(errors))`: the turn is removed from the thread, as an output guardrail's block removes it, both calls stay in `usage`, `recover` has nothing to continue, and the next turn runs from before it. The format is the agent's own: a handoff target answers in its own format, or freely. --- ## Safety Defaults - **Agent step limit**: an agent makes at most `maxSteps` model calls per turn, `Agent.DefaultMaxSteps` (50) unless you call `withMaxSteps(n)`. The count is shared by every agent a turn hands off to; at the limit the result is `AgentStatus.StepLimitReached`. The limit means the same through `run`, `start`, `stream`, `continueConversation` and `runMultiTurn`: each turn gets its own `maxSteps` model calls. - **Run token budget**: off by default. `RunConfig().withBudgets(RunBudgets.default.withMaxTokens(n))` bounds the input and output tokens one run (`run`, `recover` or `resume`) may use, counted from the thread's usage when the run started. Before each model call - the loop's own, and every call a middleware makes through `next` or `ModelContext.accounted` - the run's tokens are compared with it, and at the budget the turn ends `AgentStatus.TokenLimitReached` without calling the model. A call in flight is never interrupted, so a run can overshoot the budget by one model call. A completion that reports no usage is charged an estimate of the whole request (messages, tool definitions and response schema) and the whole answer, never zero. A retrying client (`ReliableClient`) is one call: its failed attempts carry no usage and are not counted, so set `maxTokens` with retries in mind. Each run brings its own budget, like its `timeout`; a task reads the run's deadline as `RunContext.deadline`. - **HTTPTool methods**: `HttpConfig()` defaults to `GET` and `HEAD` only. Use `HttpConfig.withWriteMethods()` or `HttpConfig().withAllMethods` to allow write methods. --- ## Core Concepts ### Threads and Results A conversation lives in a thread on a `GraphRuntime` (in memory unless you call `withRuntime`). The thread holds the messages, the active agent and the usage; it holds no tools, guardrails or agents, which belong to the `Agent`. `AgentResult` is a value to read, not something to pass back in. - `agent.start(threadId, query)` returns an `AgentRun` at once, with `await()` and `cancel()`; `run` is `start` followed by `await`. - A run that failed or was cancelled leaves its thread recoverable: `agent.recover(threadId)` re-runs only the work that did not finish. - A run that suspended for a person (`AgentStatus.Suspended`) - an approval, a tool's or a middleware's question, or a static breakpoint - continues with `agent.resume(threadId, answers)`, building each answer with `result.approve(id)`, `result.reject(id, reason)`, `result.edit(id, arguments)`, `result.reply(id, value)` or `result.proceed(id)`; see [Human Review](human-review). - A thread stays in the runtime until `agent.forget(threadId)` removes it - one-shot `agent.run(query)` threads too, which on the default in-memory runtime means they stay in memory. Forget a conversation you will not continue: `agent.run(query).flatMap(r => agent.forget(r.threadId).map(_ => r))`. `forget` refuses a thread that a run still holds - in this runtime, or by a live claim in any runtime sharing the store (`ThreadBusy`) - or that belongs to another tenant (`TenantMismatch`); an unknown thread is `Right(())`. ### Suspended turns from Java and Kotlin The Java facade (`llm4s-java-api`) returns every turn as a `JAgentResult`, read without Scala types: `answer()` is an `Optional`, `messages()` a `java.util.List`, `usage()` a `JUsageSummary`, and `status()` a `JAgentStatus` whose `kind()` is the Java enum `AgentStatusKind` - `COMPLETED`, `BLOCKED`, `STEP_LIMIT_REACHED`, `TOKEN_LIMIT_REACHED` or `SUSPENDED` - with `answer()`, `guardrail()` and `reason()` as `Optional`s (see the [Java guide](../java#an-agent-turn)). A `SUSPENDED` status's `pending()` is a `java.util.List`: the turn's approvals, then its tool questions, middleware questions and breakpoints; it is empty for any other status, and `JAgent.pending(result)` is a shortcut for it. Each `PendingInterrupt` has `id()` and `kind()` (the Java enum `InterruptKind`: `APPROVAL`, `QUESTION`, `MIDDLEWARE_QUESTION` or `BREAKPOINT`). The rest are `Optional`s, present only for the kinds they belong to: `toolName()` and `argumentsJson()` when a tool call waits (always for an approval or a tool question), `reason()` for an approval, `questionJson()` - the question as JSON - for a tool's or a middleware's question, `middleware()` for a middleware question, and `node()` and `phase()` (the Java enum `BreakpointPhase`) for a breakpoint. Answer each pending item with `Answer.approve(id)`, `reject(id, reason)`, `edit(id, argumentsJson)`, `reply(id, json)` or `proceed(id)`, then call `agent.resume(threadId, answers)`, which blocks like `run` and returns an `LlmResult`. You can answer only some of them: the result is `SUSPENDED` again, with the rest still pending. ```java LlmResult turn = agent.run("Deploy the release"); while (turn.get().status().kind() == AgentStatusKind.SUSPENDED) { List answers = new ArrayList<>(); for (PendingInterrupt p : turn.get().status().pending()) { switch (p.kind()) { case APPROVAL -> answers.add(Answer.approve(p.id())); // or reject / edit case QUESTION, MIDDLEWARE_QUESTION -> answers.add(Answer.reply(p.id(), "{\"ok\":true}")); case BREAKPOINT -> answers.add(Answer.proceed(p.id())); } } turn = agent.resume(turn.get().threadId(), answers); } System.out.println(turn.get().answer().orElse("(" + turn.get().status().kind() + ")")); ``` `agent.recover(threadId)` continues a turn that failed or was cancelled, and also returns an `LlmResult`. A failed result means one of these: a malformed answer, an empty answers list (`InvalidResume`), an answer to an id the thread is not waiting for, a thread that is not suspended (`resume`), or a thread with nothing to recover. `resume` and `recover` handle an interrupt the way `run` and `continueConversation` do: interrupting the thread blocked in the call cancels the turn, and the call returns a `CancelledError` with the interrupt flag still set once the turn has ended, leaving the thread for `recover` (see [Java Threading and Cancellation](../java-threading-and-cancellation#interrupting-the-caller-cancels-an-agent-turn)). To cancel a turn without interrupting a thread, use `streamResume` or `streamRecover` and cancel the stream (see [Streaming Events](streaming#java-and-kotlin)). The Kotlin API reuses these types: every `AgentKt` turn returns a `JAgentResult`, and `when` over `status().kind()` covers it. `AgentKt.pending(result)` is the same `List`. `agent.resume(threadId, answers)` and `agent.recover(threadId)` are `suspend` functions that return the `JAgentResult` or throw `LLMException`. Every `AgentKt` suspend function that runs a turn - `run`, `continueConversation`, `resume` and `recover` - runs it as the matching flow does (`stream`, `streamResume`, `streamRecover`), so cancelling the caller - a cancelled scope, `withTimeout` - cancels the turn. The call throws `CancellationException` once the turn has ended, and the conversation thread is no longer busy: it is left for `recover`, which finishes the cancelled turn. If the turn had already completed when the cancellation arrived, the call still throws `CancellationException`, but the turn's result is committed to the thread: `recover` then has nothing to recover and throws `LLMException` ("no incomplete execution"), and the next turn continues from that result. The one-shot `run(query)` is the exception, as in Java and Scala: nothing it throws carries its random thread id, so once a failed or cancelled turn has ended it forgets the thread. A conversation you named for `stream` is yours to forget: `agent.forget(threadId)` needs no result, succeeds for an unknown thread and throws `LLMException` for one whose turn is still running. A cancelled call waits up to 5 seconds for the turn to end; a provider that ignores the interrupt for longer is left to finish on its own, and the thread stays busy (`ThreadBusy`) until it does. ```kotlin var turn = agent.run("Deploy the release") while (turn.status().kind() == AgentStatusKind.SUSPENDED) { val answers = turn.status().pending().map { p -> when (p.kind()) { InterruptKind.APPROVAL -> Answer.approve(p.id()) InterruptKind.QUESTION, InterruptKind.MIDDLEWARE_QUESTION -> Answer.reply(p.id(), """{"ok":true}""") InterruptKind.BREAKPOINT -> Answer.proceed(p.id()) } } turn = agent.resume(turn.threadId(), answers) } ``` ### Agent Lifecycle ``` Query | v input node (input guardrails) ---> Blocked (nothing stored) | v model <---------- tool results | +-- tool calls --> call-tool (parallel, bounded) --> collect --> model +-- handoff -----> the target agent's model | v finish node (output guardrails) ---> Completed | Blocked (turn removed) ``` ### Parallel Tool Calls The tool calls of one model message run in parallel, bounded by `RunBudgets.maxConcurrency`, which you set through the `RunConfig` you pass to `run`. `ToolExecutionStrategy` remains in core as a `ToolRegistry` feature; the agent no longer takes one. ### Tool Side Effects **Every tool call gets exactly one result.** The agent, not the tool, writes the result the model sees, for every outcome: the tool's content on success, and an `{"error": ...}` result for a tool error, a thrown exception, invalid arguments, an unknown tool, a denial, a rejected approval and every call of a refused handoff batch. The model is called again only once every call of the batch has its result, so a call waiting for approval or an answer holds the model back, and a partial `resume` sends nothing to it. Before each model call the agent checks the request, as every `wrapModelCall` middleware left it, and refuses one in which a tool call does not have exactly one result straight after its message; a model message whose call ids repeat, or are blank, is refused before it is stored, and `history` you import must keep the same rule. The OpenAI and Anthropic clients are checked against this contract in both wire formats (`assertOneToolResultPerCall` in `llm4s-provider-testkit`). **A call cut short gets no result until it runs again.** When a turn is cancelled, times out, or fails on a tool's `Fatal` or a store error mid-batch, the calls that finished keep their results and the unfinished ones get none: the thread is left for `recover`, which runs only the unfinished calls and never repeats a finished one. `start` refuses the thread until then (`IncompleteRun`). Until [#1702](https://github.com/llm4s/llm4s/issues/1702) adds `updateState`, there is no way to give an unfinished call a result of your own: a call that misbehaves every time it runs - it hangs until cancelled, or fails with `Fatal` - leaves the thread two choices, `recover` (run it again) or `forget` (drop the thread and its history). **Tools run at least once, so pass the idempotency key on.** A call can run again: a retrying `wrapToolCall` middleware, `recover` after a cancellation, failure or crash, and an approval each run it once more, and a call cut short may already have done its work. With a store shared between processes, a call can even run in two runs at the same time: a worker paused past its claim's lease can still be running a tool when another runtime takes the thread over and runs the same call (see [Fencing](durable-checkpointers#fencing-a-run-that-lost-its-claim-stops-and-records-nothing)). `ToolContext.idempotencyKey` is the same in every run of one call, so a tool with an external side effect passes it to the system it calls, which then does the work once: (not compile-checked) ```scala import org.llm4s.agent.graph.tool.{AgentTool, ToolContent, ToolOutcome} val charge = AgentTool(chargeSpec) { (args, context) => payments.charge(args.amount, idempotencyKey = context.idempotencyKey.value) match case Right(receipt) => ToolOutcome.Success(ToolContent.Json(ujson.Str(receipt.id))) case Left(error) => ToolOutcome.Error(error.message) } ``` A plain `ToolFunction` - one built with `ToolBuilder`, a built-in tool, an MCP tool - reads the same call as `params.call`, a `ToolCallInfo` with the call's `toolCallId` and `idempotencyKey`. It is `None` when the function runs outside an agent, through `ToolRegistry.execute`, which has no call to describe: (not compile-checked) ```scala import org.llm4s.toolapi.{ Schema, ToolBuilder } val charge = ToolBuilder[Map[String, Any], String]("charge", "Charges a card", chargeSchema) .withHandler { params => for amount <- params.getDouble("amount") receipt <- payments.charge(amount, idempotencyKey = params.call.map(_.idempotencyKey)) yield receipt.id } .buildSafe() ``` Two tools pass it on by themselves. `HTTPTool` adds `Idempotency-Key: ` to a POST, PUT, PATCH or DELETE it sends as an agent's call, unless the call's own headers set one. An MCP tool sends it to its server in the `tools/call` request's `_meta`, as `llm4s.org/idempotencyKey` (with `llm4s.org/toolCallId`), never among the arguments; an llm4s `MCPServer` hands it to its tool as `params.call`, and another server can read it there or ignore it. A tool defined from Java (`llm4s-java-api`) reads the same call from its `JToolArgs`: `idempotencyKey()` and `toolCallId()` are `Optional`s, empty outside an agent run. A `JTool.builder` lambda reads them from its `args`, and a `@Tool` method from a parameter of type `JToolArgs`, which the model does not see (see the Java guide's [Tools](../java#tools)): ```java LlmResult refund = JTool.builder("refund", "Refunds a charge once") .param("charge", String.class, "The charge to refund") .handler(args -> billing.refund(args.getString("charge"), args.idempotencyKey().orElse(null))) .build(); ``` A tool declared with the Kotlin DSL (`llm4s-kotlin-api`) reads it as `args.idempotencyKey`, `null` outside an agent run ([The Kotlin DSL](../java#the-kotlin-dsl)): ```kotlin tool("refund", "Refunds a charge once") { param("charge", "The charge to refund") handler { args -> billing.refund(args.get("charge"), args.idempotencyKey) } } ``` The key is 64 hexadecimal characters, derived from the thread, where on it the model call that issued the tool call ran (its checkpoint, superstep and task), and the call's id, and recorded with the call. A call of a later model request gets a new key even when the provider reuses its call id (`call_0` in every turn) and the turns share one `RunConfig`, and so does a call from a model request made again because the first response was never stored. The boundaries of the guarantee: - The key de-duplicates only where the external system honours it. Without one, a side effect can happen more than once; the runtime never promises exactly-once execution. - A tool sees the key only when it reads it: `ToolContext.idempotencyKey` in an `AgentTool`, `params.call` in a `ToolFunction`, `idempotencyKey()` in a Java tool's `JToolArgs`, `idempotencyKey` in a Kotlin tool's `ToolArgs`. One that ignores it runs at least once like any other. - An approved or edited call keeps the key it was proposed with, and a call that asked a question keeps it when it resumes. Make a tool's first run, before approval or an answer, free of side effects. - The agent stores each call's result as soon as the call finishes (the runtime's `Sync` durability), and a stored result is never run again. A process that dies between a tool's side effect and that store runs the call again on `recover`, with the same key. A graph run under `Async` or `OnExit` durability can lose more after its last durable commit: finished calls run again with their keys, and a lost model response is asked for again, so its calls get new keys. - A stale worker and its successor send the same key for a call both run, so a key-honouring system does the work once. A system that can fence writers can also refuse the stale one outright: `context.run.position.fencingToken` is the run's claim token, which grows with every claim on the thread, so the system keeps the highest token it has seen and refuses a lower one. **Guarded effects: replay without repeating.** For a target that does not take an idempotency key, such as a file, a sandbox or a store, perform the effect through `ReplayGuard`. The guard records an intent before the effect and its outcome after, in the thread's own store, under the run's claim. A call that runs again then gets its original outcome back instead of repeating the work. The target declares one `ReplaySupport`, and each has its own guard: - `ReplayGuard.transactional` is for a target that records the key with the outcome in the same transaction as the effect, and implements `AppliedKeys`. The effect and `forget` are given the run's claim token, and the target refuses a token older than the newest it has seen for the key's `scope` (its thread). So a run that lost its claim can neither apply, nor read back, nor forget anything once its successor has touched the target. - `ReplayGuard.verifiable` is for a target that can read a digest (`StateCheck`) of what the effect touches. If the effect began but its outcome was not recorded, the guard checks the target. It returns the presumed outcome if the effect landed, runs the effect again if it did not, and refuses if another thread changed the target in between. The effect must be a compare-and-set against `before`, so that a stalled run's write cannot land on its successor's. - `ReplayGuard.unverifiable` is for everything else. If the effect began but its outcome was not recorded, the guard refuses to run it again ("it may already have run"), unless the tool is idempotent. (not compile-checked) ```scala import org.llm4s.agent.graph.{GraphError, ReplayGuard, StateCheck} val append = AgentTool(appendSpec) { (args, context) => val key = context.effectKey("file.append", args.path) ReplayGuard.verifiable[Int](context.run, key)(Right(StateCheck.of(read(args.path)))) { val before = read(args.path) val after = before + args.text Right(ReplayGuard.Plan(StateCheck.of(before), StateCheck.of(after), after.length) { // under a lock every writer takes: write only if the file is still what the plan read writeIfUnchanged(args.path, StateCheck.of(before), after).map(_ => after.length) }) } match case Right(length) => ToolOutcome.Success(ToolContent.Json(ujson.Num(length))) case Left(r: GraphError.EffectReplayRefused) => ToolOutcome.Error(r.message) case Left(other) => ToolOutcome.Fatal(other) // outcome unknown: recover replays it } ``` What each guard does when the run crashed at each point, and why, is in design record [§4.24](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.24-stage-3-slice-c4-replay-of-external-effects.md). The rules a caller relies on: - **Build each key with `effectKey`.** In a tool, `context.effectKey(operation, discriminator*)` derives the key from the call's idempotency key. In a hook or node, `RunContext.effectKey` derives it from the task's `taskKey`. Give each effect of a task its own operation or discriminator. - **A fork's re-run of a call is a new effect.** The key includes the thread. - **The evidence is kept exactly as long as the task can run again.** That is until the commit that finishes the task, however long the thread waits. `prune` never removes it, and `deleteThread` removes it with the thread. - **A Block does not undo an external effect.** A guardrail that blocks a turn restores every state key the turn wrote, its messages included (§4.22), but a file the turn wrote outside thread state, a command it ran or a record it stored stays as it is. The turn's replay evidence goes with its closing commit, so nothing replays it either. ### Harness File Backends The harness keeps an agent's files in a `FileBackend` (`org.llm4s.agent.harness`): thread state (`StateFileBackend`, the default), a long-term store (`StoreFileBackend`, for `/memories/`), several at once (`CompositeBackend`), files the application ships, read-only (`StaticFileBackend`: mount it at `/app` and point `SkillsConfig.withSources` and `InstructionsConfig.withPaths` there, since on state files only a tool call writes a file), a directory of the host (`HostFileBackend`), or one chosen per run (`FileBackend.resolved`). Each backend declares the locks its mutations hold, replays its external effects through `ReplayGuard`, and enforces its own size limits. `HostFileBackend.open(root, lockDirectory)` exposes a trusted directory as `/` ([design §4.40](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.40-host-file-backend.md)). It is the root's only writer: it holds an OS lock on the root until `close()`, and any other open of the same root, or of one inside or around it, in this process or another, is refused with `HostRootInUse`. Give every process on the host one `lockDirectory`, outside every root, on a local file system that nothing prunes. It refuses symbolic links, files with other hard links, mounts beneath the root and volumes that make 8.3 short names; on Windows, which reports no hard-link counts, pass `allowUncheckedIdentity = true` only for a root with no hard links. It is not a security boundary: never give it a root an untrusted process can write, and run untrusted commands in the workspace sandbox. The backend holds the root's lock until `close()`, so give it a managed lifetime: one that closes it however the work ends. Until it is closed, every other open of the root is refused. (not compile-checked) ```scala import org.llm4s.agent.graph.tool.ToolSet import org.llm4s.agent.harness.{FileTools, HostFileBackend} import java.nio.file.Path import scala.util.Using HostFileBackend.open(Path.of("/srv/project"), Path.of("/var/lib/myapp/llm4s-locks")).flatMap { opened => Using.resource(opened) { project => // closed when the block ends, whether it succeeds or fails for { tools <- ToolSet.of(FileTools(project)*) agent <- Agent.builder("coder", client).withTools(tools).build() result <- agent.run("Summarise README.md") } yield result } } ``` An application that keeps an agent for longer keeps the backend beside it, and closes it when it shuts the agent down. ### Harness File Tools `FileTools(backend)` gives the model seven tools over any `FileBackend`: `ls`, `read_file`, `write_file`, `edit_file`, `delete`, `glob` and `grep` ([design §4.32](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.32-stage-3-slice-f1-filesystem-tools.md)). (not compile-checked) ```scala import org.llm4s.agent.graph.tool.ToolSet import org.llm4s.agent.harness.{FileToolLimits, FileTools, StateFileBackend} for { tools <- ToolSet.of(FileTools(StateFileBackend(), FileToolLimits(maxResultChars = 20000))*) agent <- Agent.builder("coder", client).withTools(tools).build() } yield agent ``` - **Paths are absolute** (`/src/main.scala`); `.`, `..` and empty segments are refused. - **`read_file`** numbers lines from 1 and pages with `offset` (the line to start at) and `limit` (default and at most `readLimit`, 2000), and says how many lines the file has. **`edit_file`** replaces `old_string` with `new_string`, and refuses when `old_string` is absent, or occurs more than once without `replace_all`. - **Bounded results.** Lines are cut at `maxLineChars`; `ls`, `glob` and `grep` stop at `maxResults` and say so; every result a tool returns, an error's included, is at most `maxResultChars` characters (an outcome the loop or a middleware gives instead, such as a denial, is theirs). - **Order in one message.** Mutations take the backend's locks, so two edits of one file in one message apply in order, the second against the first's result. Reads take no lock: a read in the same message as a write may not see it (over thread state it sees the file as before the message). - **Permissions.** Each tool declares its resources: `Read` of the path for `ls` and `read_file`, `Write` for `write_file` and `edit_file`, `Delete` for `delete`. `glob` and `grep` declare the envelope of what they may read (`/src/**`, `/src/*.md`), which a `PermissionMiddleware` decides as a whole, so a deny rule beneath the searched path denies the search. `ls` shows the names, not the contents, of entries beneath a path it may read. - **Block and replay.** An output Block restores state files with the rest of the turn; writes to a store are not undone. A store mutation that runs again after a crash replays its recorded outcome. - **Not with the host file tools.** `llm4s-agent-tools`' `ReadFileTool` and `WriteFileTool` have the names `read_file` and `write_file`, and a `ToolSet` refuses duplicate names: use one suite or the other. - `FileToolsContract` (`llm4s-agent-testkit`) runs the tools over your own `FileBackend`. ### Workspace Sandbox `llm4s-workspace-client` puts a containerised workspace behind both backend SPIs: `WorkspaceFileBackend` for its files and `WorkspaceSandboxBackend` for its commands (`org.llm4s.agent.harness.workspace`). The `execute` tool (`ExecuteTool`, in `llm4s-agent`) runs a command in any `SandboxBackend`: (not compile-checked) ```scala import org.llm4s.agent.harness.ExecuteTool import org.llm4s.agent.harness.workspace.{WorkspaceFileBackend, WorkspaceSandboxBackend} val workspace = new ContainerisedWorkspace(dir, image, port) // started with startContainer() for files <- WorkspaceFileBackend("ws1", workspace) // a FileBackend for the file tools sandbox <- WorkspaceSandboxBackend("ws1", workspace) agent <- Agent.builder("coder", client) .withTools(ToolSet.of(ExecuteTool(sandbox)).toOption.get) .build() yield agent ``` - **Permissions.** An `execute` call touches `(Execute, )` and is `destructive` and `openWorld`, so `PermissionRule.allow(Operation.Execute, "sbt *")` lets the agent run builds and nothing else. The runner refuses anything its command policy forbids as well ([Workspace sandbox](../../reference/workspace-sandbox.html)), and enforces `allowedPaths`, `readOnlyPaths` and its size limits itself. - **Order.** A file mutation and a command both lock the whole workspace (`files/workspace/`), so a batch's edits and commands in one workspace apply one after another, in call order. Two paths that name one file through a link are ordered too. - **Replay.** File mutations are `Verifiable`: each is a compare-and-set against the file's hash, which the runner applies atomically, so a replayed edit that landed returns its first result and one whose file changed meanwhile is refused. Commands are `Unverifiable`: a completed command replays its stored result, and one that may have run is refused, never run again. - **Effects stay.** What a command or a file write did to the workspace is outside thread state: a Block that retracts the turn does not undo it. The runner never runs a file change beside a command it started, but a process a command leaves running, or a writer outside the container, is outside the compare-and-set. - **Tenants.** One workspace is one tenant's. An application serving several builds a pair per tenant and picks it with `FileBackend.resolved` and `SandboxBackend.resolved(_ => Set(WorkspaceLocks.AllWorkspaces))`. ### Tool Permissions, Timeouts and Locks An `AgentToolSpec` can declare what a call touches, how long one run may take, and which locks it holds, each from the call's decoded arguments: (not compile-checked) ```scala import org.llm4s.agent.graph.middleware.{PermissionMiddleware, PermissionRule} import org.llm4s.agent.graph.tool.{LockKey, Operation, ToolResource} import scala.concurrent.duration.* val writeSpec = baseSpec .withResources(args => Vector(ToolResource(Operation.Write, args.path))) .withLocks(args => Set(LockKey("workspace", workspaceId, args.path))) .withTimeout(30.seconds) val agent = Agent.builder("coder", client) .withTools(tools) .withMiddleware( // register it first, so it wraps every other middleware new PermissionMiddleware(Seq( PermissionRule.deny(Operation.Write, "/etc/**"), PermissionRule.ask(Operation.Write, "/deploy/**"), PermissionRule.allow(Operation.Write, "/src/**"), PermissionRule.allow(Operation.Invoke, "*") )) ) .build() ``` **Permissions are deny-if-unmatched.** For each resource the first rule with its operation whose `Glob` matches the target decides, and a resource no rule matches is denied. A call runs only if every resource is allowed: a denial is the error result `Denied: `, an `Ask` suspends the call for approval. A tool that declares no resources touches `(Invoke, )`, so it needs an `Invoke` rule. Resources are the call's whole envelope: a tool that asks a question and lets the answer choose its target declares a resource covering every target it may pick (`/out/**`) and refuses an answer outside it, because the check runs on the declaration, not on what the tool does. Such an envelope is checked for every target in it: a deny rule reaching any part of it denies the call, and it is allowed only when allow rules cover all of it. Approval is one flag per call: once a reviewer approves the call, every `Ask` passes. An edited call is checked again on its new arguments, so a deny rule still refuses it. Rules see the arguments after the schema check and decoding. In a `Glob`, `*` and `?` stay within one `/`-separated segment and `**` crosses them. An `Execute` rule limits which commands the agent may ask to run, not what a command does once it runs: rely on the sandbox for that. **A timeout bounds each run of the tool.** A run past it is interrupted and the model sees the error `Tool '' timed out after ms`; the run continues. The tool runs on a thread of its own, and the call waits for that thread to finish, so **block interruptibly**: a tool that ignores its interrupt holds its whole batch past the timeout. A timed-out call may have done part of its work: it keeps its idempotency key if it runs again (a retrying middleware, `recover`), but if the model calls the tool again, that is a new call with a new key. Cancelling the run still cancels the call, which records nothing. **Locks order conflicting calls of one batch.** Two `LockKey`s conflict when one is a prefix of the other, segment by segment: `workspace/w1` conflicts with `workspace/w1/src/a.scala`, and `a/b` does not conflict with `a/bc`. When a model message has several calls, a call whose locks conflict with an earlier call's waits for it and runs in a later superstep, in call order, seeing what the earlier call committed. So a check against thread state (a quota, a file's content) is exact, and two calls writing one key no longer lose one of the writes. Calls without conflicting locks still run in parallel. Each further round costs two supersteps of `RunBudgets.maxSupersteps`. A call that waits for approval holds back the calls queued behind it, so a reviewer can see a batch's approvals in more than one round. Locks order calls within one batch only, not across threads. An approval edit may narrow a call's locks (`/prod` to `/prod/app`) but not take a lock its proposed arguments did not cover: such an edit is refused, the call does not run, and the model sees why. ### Todos `TodoMiddleware` gives an agent a plan: a `write_todos` tool that replaces the thread's todo list, and a prompt section that shows the list to the model on every model step. (not compile-checked) ```scala import org.llm4s.agent.harness.{HarnessEvents, TodoMiddleware} for { agent <- Agent.builder("researcher", client) .withMiddleware(new TodoMiddleware(maxTodos = 20, maxTodoChars = 200)) .build() // Result[Agent] run <- agent.stream(threadId, "Write a report on ...") { case HarnessEvents.TodosProgress(p) => p.todos.foreach(t => println(s"[${t.status.wire}] ${t.content}")) case _ => () } result <- run.await() } yield result ``` - **The model sends the whole list** - each item's `content`, `status` (`pending`, `in_progress`, `done`) and, to keep an item, its `id` - and it replaces the plan. The tool assigns ids: an item without one keeps the id of an item with the same content, or gets a new one, and an id is never reused within a thread. An unknown id, an id given twice, a list over `maxTodos` (default 50) or an item over `maxTodoChars` code points (default 200) is refused as an error the model sees. - **The plan is thread state**, in `TodoMiddleware.key` (a `TodoList`): it is checkpointed, read by the next turn, and restored by an output Block with the rest of the turn. Two `write_todos` calls in one message run in turn (the tool locks `todos`), the second seeing the first's list. - **Events.** Each change emits a durable `HarnessEvents.TodosUpdated` with ids and statuses only, and a live `HarnessEvents.TodosProgress` with the whole list. - **The plan guides the model only.** It changes no route and no permission: a `PermissionMiddleware` needs `PermissionRule.allow(Operation.Invoke, "write_todos")` for the tool. - **Adding it to an existing agent reshapes its graph** (it registers the `todos` key, and the tool's lock registers `tool-queue`), so threads checkpointed before are refused with `RestoreRejected`: finish or `forget` them first. ### Skills `SkillsMiddleware` gives an agent [Agent Skills](https://agentskills.io/): folders holding a `SKILL.md` whose frontmatter names and describes the skill. It finds them through a `FileBackend` and lists each skill's name, description and path in one prompt section; the model reads a skill's `SKILL.md`, and its other files, with `read_file` when a task matches it. (not compile-checked) ```scala import org.llm4s.agent.harness.{SkillsMiddleware, VPath} for { user <- VPath("/user/skills") project <- VPath("/project/skills") agent <- Agent.builder("coder", client) .withMiddleware(new SkillsMiddleware(Vector(user, project), backend)) .build() // Result[Agent] } yield agent ``` - **Give the agent `FileTools(backend)` over the same backend** (or the composite it is mounted in), so the paths in the section are the ones `read_file` takes. - **Read once per run**, on its first model call, and cached for the run. A skill added or edited during a run shows from the next run; a recovered or resumed run reads again. A later source's skill replaces an earlier one of the same name, so list the user's skills before the project's. - **Bounded.** At most `maxSkills` (default 20) skills, each description cut at `maxDescriptionChars` (256), the section at most `maxSectionTokens` (1500), at most `maxScanned` (100) folders examined, and only each `SKILL.md`'s frontmatter read. - **The frontmatter is a restricted YAML subset**, read without a YAML library: `key: value` scalars, quoted strings, flat lists and a one-level `metadata` map. A block scalar (`description: |`), a nested value or a name that does not match its folder makes the skill malformed. - **Problems never fail the run.** A malformed, unlisted, shadowed or unreadable skill is skipped and named by path in a durable `HarnessEvents.DocumentsReported`. A missing source is skipped. - `allowed-tools` is parsed onto `Skill.allowedTools`, not enforced. ### Instruction Memory `InstructionMemoryMiddleware` shows standing instructions kept in `AGENTS.md` files to the model, as one prompt section: the files at the paths given, read through a `FileBackend` in order (a later file refines an earlier one), each under a heading naming its path. (not compile-checked) ```scala import org.llm4s.agent.harness.{InstructionMemoryMiddleware, VPath} for { user <- VPath("/user/AGENTS.md") project <- VPath("/project/AGENTS.md") agent <- Agent.builder("coder", client) .withMiddleware(new InstructionMemoryMiddleware(Vector(user, project), backend)) .build() // Result[Agent] } yield agent ``` - **Read once per run**, as skills are: an edit during a run shows from the next run, and a recovered or resumed run reads again. A missing file is skipped. - **Editable when permitted.** The agent edits a file with `edit_file` (`FileTools(backend)`) over the same backend when the backend accepts the write and the permission rules allow `Write` on its path; deny it to keep a scope read-only. - **Bounded.** Each file at most `maxFileTokens` (default 1000), the section at most `maxTotalTokens` (2000); a file over either is cut and marked, files past a full section are left out, and each is named by path in a durable `HarnessEvents.DocumentsReported`. - Instruction memory is a few whole files the agent always sees; [semantic memory](memory) is facts recalled per query. ### Harness Profile `HarnessProfile` assembles the pieces above into one value, installed with `withHarness`: (not compile-checked) ```scala import org.llm4s.agent.graph.middleware.PermissionRule import org.llm4s.agent.graph.tool.Operation import org.llm4s.agent.harness.{HarnessProfile, PromptMode} import org.llm4s.config.HarnessConfigLoader for { profile <- HarnessConfigLoader.load() // HarnessProfile.deepAgents, with llm4s.agent.harness applied agent <- Agent.builder("researcher", client) .withHarness( profile .withSystemPrompt(PromptMode.Suffix("Write the report to /report.md.")) .withPermissions(Vector( PermissionRule.allow(Operation.Read, "/**"), PermissionRule.allow(Operation.Write, "/report.md"), PermissionRule.allow(Operation.Invoke, "write_todos") )) ) .build() // Result[Agent] } yield agent ``` - **`HarnessProfile.deepAgents`** turns everything on at its defaults: the file tools over a `StateFileBackend`, todos, summarisation (the model-free digest compressor), offload of large tool results, skills under `/skills` and instructions in `/AGENTS.md`. `HarnessProfile()` is the file tools alone. Change either with the `with*` setters: `withBackend`, `withSandbox` (adds `execute`), `withTools(ToolSelection.Only(...))`, `withTodos(None)`, `withToolTimeout`, `withRetention`, ... - **Subagents.** `deepAgents` also offers the general-purpose subagent through the `task` tool; `HarnessProfile()` offers none. See [Subagents in a harness](delegation#subagents-in-a-harness). - **The order is fixed**, outermost first: permissions, subagent delegation, summarisation, offload, skills, instructions, todos, then the builder's own middleware. With no permission rules there is no `PermissionMiddleware`; with rules, a call no rule allows is denied. - **The prompt** is the profile's (plan, act and verify; context; tool safety), `PromptMode.Replace` or `Suffix`. `withSystemPrompt` beside a harness is refused. - **Retention.** As each turn is admitted, the thread is pruned to its latest 20 checkpoints (each is a full snapshot of the files, plan and summary), so a finished turn stays whole until the next one starts, and a refused turn changes nothing; `withRetention(RetentionPolicy.keepAll)` keeps everything. `RunConfig.withRetention` does the same for any run. - **Defaults live in `llm4s-agent`'s `reference.conf`**, under `llm4s.agent.harness`; override them in `application.conf`. Backends, a sandbox, permission rules and a model compressor are set in code. - **Middleware order is checked.** A middleware that must run outside another says so (`AgentMiddleware.placement`), and `build()` refuses the wrong order: a `SummarizationMiddleware` inside a `ToolOutputOffload`, or a `SemanticMemoryMiddleware` inside guardrail middleware. --- ## Features ### [Guardrails](guardrails) Validate inputs and outputs for safety. Guardrails are middleware on the agent: (not compile-checked) ```scala import org.llm4s.agent.graph.middleware.GuardrailMiddleware import org.llm4s.agent.guardrails.builtin._ val agent = Agent.builder("assistant", client) .withMiddleware( new GuardrailMiddleware( input = Seq(new LengthCheck(1, 10000), new ProfanityFilter()), output = Seq(new JSONValidator()) ) ) .build() ``` A blocked run ends `AgentStatus.Blocked(guardrail, reason)`, the blocked turn removed from the thread, which stays usable. [Learn more about guardrails →](guardrails) ### [Memory System](memory) Persistent context across conversations: (not compile-checked) ```scala import org.llm4s.agent.memory._ val result = for { manager <- SimpleMemoryManager.empty m1 <- manager.recordUserFact("Prefers Scala", Some("user-1"), Some(0.9)) context <- m1.getRelevantContext("programming preferences") } yield context ``` `SemanticMemoryMiddleware`, in `llm4s-agent-memory`, recalls a tenant's memories before each turn and records the turn after it, safely across `recover`: [Semantic Memory Middleware](memory#semantic-memory-middleware). [Learn more about memory →](memory) ### [Handoffs](handoffs) Delegate to specialist agents: (not compile-checked) ```scala import org.llm4s.agent.Handoff val physics = Agent.builder("physics", client) .withSystemPrompt("You are a physicist") val agent = Agent.builder("triage", client) .withHandoffs(Handoff.to("physics", physics, "Physics expertise required")) .build() ``` [Learn more about handoffs →](handoffs) ### [Human Review](human-review) Pause a turn for a person: tool approvals, questions from tools and from middleware - to review or edit an answer, or for missing information - and static breakpoints before or after an agent's model calls, tool calls and final answer. Answer any subset of what a turn waits for; the rest stays pending. ### [Streaming Events](streaming) `Agent.builder(...).withStreaming()` streams the model's answer, and `agent.stream(threadId, query)(listener)` delivers every event of the turn - text deltas, tool calls, model calls, handoffs, guardrail blocks - as a `StreamEvent`, matched with `AgentEvents`. Durable events carry no message content; content is live-only. `AgentIO.stream` and `AgentZ.stream` give the same events as fs2 and ZIO streams. [Learn more about streaming →](streaming) ### [Durable Checkpointers](durable-checkpointers) Every run claims its thread in the `Checkpointer` before it starts, renews the claim while it runs, and fences every commit with the claim's token. Runtimes sharing one store - in one process or many - are refused a live run's thread with `ThreadBusy`, can `recover` a run whose process died once its claim has expired, and a run that lost its claim records nothing more. `SqliteCheckpointer` (`llm4s-agent-checkpoint-sqlite`) keeps threads in a SQLite file, so a run survives a restart and `recover` continues it in the next process. `CheckpointerContract` in `llm4s-agent-testkit` tests a store of your own. [Learn more about durable checkpointers →](durable-checkpointers) ### [Running agents on several machines](multiple-machines) Several instances of one agent service share threads through one PostgreSQL database: runs are submitted to a durable queue and executed by whichever instance's `AgentWorker` takes them, a run whose machine dies is recovered by another, any instance can follow or cancel any run, and a message for a busy thread is rejected, queued or interrupts the running turn, as the caller chooses. [Learn more about running on several machines →](multiple-machines) --- ## Built-in Tools The `llm4s-agent-tools` module ships ready-made tools - a calculator, the date and time, UUIDs, JSON, files, HTTP, a shell and web search - in bundles you register with an agent. They work with plain tool calling through `ToolRegistry` too, without an `Agent`. The [Built-in Tools guide](/guide/builtin-tools) lists every tool and its parameters, and says what each bundle lets a model do: read its safety section before you give a model files, the network or a shell. For the module itself, see [installation](/getting-started/installation#for-built-in-tools-web-search-http-filesystem-shell). (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.toolapi.ToolRegistry import org.llm4s.toolapi.builtin.BuiltinTools val result = for { tools <- BuiltinTools.coreSafe // date/time, calculator, UUID, JSON: no files, network or processes agent <- Agent.builder("assistant", client).withTools(new ToolRegistry(tools)).build() state <- agent.run("What is 15% of 850?") } yield state ``` The bundles, smallest to largest: `coreSafe`, `withHttpSafe()` (adds read-only HTTP), `withFilesSafe()` (adds read-only files) and `developmentSafe()` (adds file writing and a shell). `customSafe(...)` builds exactly the tools whose configuration you pass. --- ## Context Window Management Handle long conversations with `ContextWindowMiddleware`. It prunes what is sent to the model on each call; the thread keeps its full history: (not compile-checked) ```scala import org.llm4s.agent.{ContextWindowConfig, PruningStrategy} import org.llm4s.agent.graph.middleware.ContextWindowMiddleware val config = ContextWindowConfig( maxMessages = Some(20), preserveSystemMessage = true, minRecentTurns = 2, pruningStrategy = PruningStrategy.OldestFirst ) val agent = Agent.builder("assistant", client) .withMiddleware(new ContextWindowMiddleware(config)) .build() ``` The current turn (the latest user message onward) is never pruned, a tool call is never separated from its result, and the request always starts with a user message - so a request may exceed the budget. The system prompt is outside the budget. **Pruning Strategies:** | Strategy | Behavior | |----------|----------| | `OldestFirst` | Remove oldest messages first (FIFO) | | `MiddleOut` | Keep first and last messages, remove middle | | `RecentTurnsOnly(n)` | Keep only the last N conversation turns | | `Custom(fn)` | User-defined pruning function | ### Summarisation `SummarizationMiddleware` (`org.llm4s.agent.harness`) replaces the older part of each request with a summary instead of dropping it. When the outgoing request is over its trigger, every message but the last `keepRecent` is folded into the summary, which goes into that request and is kept in the thread for the next ones; when it fires again, the summary is extended, not recomputed. The stored history is never edited. (not compile-checked) ```scala import org.llm4s.agent.harness.{SummarizationMiddleware, SummaryCompressor, SummaryTrigger} val agent = Agent.builder("assistant", client) .withMiddleware( new SummarizationMiddleware( trigger = SummaryTrigger.Tokens(60000), keepRecent = 10, compressor = SummaryCompressor.model(summariserClient), maxSummaryTokens = 2000 ) ) .build() ``` `SummaryCompressor.model(client)` asks a model for the summary; its calls are counted in the thread's usage. `SummaryCompressor.digest(counter)` uses core's `HistoryCompressor`, with no model call. The summary never exceeds `maxSummaryTokens`, a tool call is never separated from its results, and an output Block discards a blocked turn's summary. Use summarisation or `ContextWindowMiddleware`, not both. ### Large tool results `ToolOutputOffload` keeps a tool result over `thresholdTokens` out of the conversation: the model sees a header, the first lines, and a path such as `/large_tool_results/`. The whole result (up to `maxResultBytes`) is kept in the thread, oldest evicted first beyond `maxTotalBytes` (4 MiB by default). Mount `OffloadBackend` so file tools can read it: (not compile-checked) ```scala import org.llm4s.agent.graph.tool.ToolSet import org.llm4s.agent.harness.{CompositeBackend, FileTools, OffloadBackend, StateFileBackend, ToolOutputOffload} for { files <- CompositeBackend(StateFileBackend(), ToolOutputOffload.Mount -> OffloadBackend) tools <- ToolSet.of(FileTools(files)*) agent <- Agent.builder("assistant", client) .withTools(tools) .withMiddleware(new ToolOutputOffload(thresholdTokens = 20000)) .build() } yield agent ``` The mount is read-only. Only text is offloaded: images and documents in a result are passed on unchanged. With summarisation, results the summary covers are dropped; register `SummarizationMiddleware` before `ToolOutputOffload`. `ToolOutputOffload` also caps every tool result the model is sent at `thresholdTokens`, including errors and denials that no tool produced. --- ## Conversation Persistence A thread lives in the runtime. To keep a conversation across processes, save the result's messages and import them as the `history` of a new thread: (not compile-checked) ```scala import upickle.default.{read, write} // Save the messages of a completed run Files.writeString(path, write(result.messages)) // Later: load them and continue on a new thread val saved = read[Vector[Message]](Files.readString(path)) val next = agent.run(ThreadId(java.util.UUID.randomUUID().toString), "Continue our conversation", RunConfig(), saved) ``` `history` is accepted only on a thread that does not exist yet, and may not contain system messages (prompts belong to the agents). Save only runs that completed; `history` in which a tool call does not have exactly one result straight after its message - one that ends mid tool call, say - is refused. --- ## Reasoning Modes Enable extended thinking for complex problems: (not compile-checked) ```scala import org.llm4s.llmconnect.model.{CompletionOptions, ReasoningEffort} val options = CompletionOptions() .withReasoning(ReasoningEffort.High) // None, Low, Medium, High .withMaxTokens(4096) // Use with agent Agent.builder("assistant", client).withCompletionOptions(options).build() ``` Supported by OpenAI o1/o3 and Anthropic Claude models. --- ## Examples | Example | Description | |---------|-------------| | [SingleStepAgentExample](/examples/#single-step) | A run with one tool call | | [MultiStepAgentExample](/examples/#multi-step) | Complete execution flow | | [MultiTurnConversationExample](/examples/#multi-turn) | Functional multi-turn API | | [LongConversationExample](/examples/#long-conversation) | Context window pruning | | [ConversationPersistenceExample](/examples/#persistence) | Save and resume | | [BuiltinToolsAgentExample](/examples/#agent-examples) | Built-in tools | | [DeepResearchExample](/examples/#deep-research) | The harness on state files: plan, offload, summary, memory, recover after a stop | | [CodingWorkspaceExample](/examples/#coding-workspace) | The harness in a workspace: permissions, approval of `execute`, resume | | [ParallelDeepResearchExample](/examples/#parallel-deep-research) | Typed subagents in parallel: bounds, a child's approval through the lead, nested streams, recover after a stop | [Browse all examples →](/examples/) --- ## Design Documents For in-depth technical details: - [Agent Framework Roadmap](/design/agent-framework-roadmap) - Strategic direction - [Phase 1.1: Conversations](/design/phase-1.1-functional-conversation-management) - Conversation API - [Phase 1.2: Guardrails](/design/phase-1.2-guardrails-framework) - Validation framework - [Phase 1.3: Handoffs](/design/phase-1.3-handoff-mechanism) - Agent delegation - [Phase 1.4: Memory](/design/phase-1.4-memory-system) - Memory architecture - [Typed Agent Runtime §4.13](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.13-stage-1-slice-2-the-agent-loop-on-the-graph-runtime.md) - How `Agent` runs on the graph runtime - [Phase 2.1: Streaming](/design/phase-2.1-streaming-events) - Event system --- ## Next Steps 1. **[Guardrails Guide](guardrails)** - Input/output validation 2. **[Memory Guide](memory)** - Persistent context 3. **[Handoffs Guide](handoffs)** - Agent delegation 4. **[Streaming Guide](streaming)** - Real-time events 5. **[Durable Checkpointers](durable-checkpointers)** - Shared stores, claims and recovery 6. **[Running agents on several machines](multiple-machines)** - One service, several instances, one Postgres 7. **[Examples Gallery](/examples/)** - Working code samples --- Source: https://llm4s.org/guide/agents/guardrails # Guardrails Validate agent inputs and outputs for safety, quality, and compliance. --- ## Overview Guardrails are validation functions that run before (input) and after (output) agent processing. They help ensure: - **Safety** - Block harmful or inappropriate content - **Quality** - Enforce response standards - **Compliance** - Meet business requirements - **Security** - Detect prompt injection and PII Guardrails belong to the agent, not to a run: give them to the builder in a `GuardrailMiddleware`. (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.agent.graph.middleware.GuardrailMiddleware val agent = Agent.builder("assistant", client) .withTools(tools) .withMiddleware( GuardrailMiddleware( input = Seq(...), // Validate each query before the LLM call output = Seq(...) // Validate each final answer ) ) .build() ``` A guardrail that refuses does not make `run` return a `Left`: the turn ends with `AgentStatus.Blocked(guardrail, reason)` - the first failing guardrail's name, and every failure's error - and the thread stays usable, so the next `run` or `continueConversation` on it works. The blocked turn is never kept: an input block stores nothing for that turn, and an output block removes the whole turn - the query, any tool calls and results, and the answer - so `result.messages` is the conversation as it was before the turn and the model never sees the blocked answer again. `usage` still counts the blocked turn's model calls. A guardrail that transforms (`PIIMasker`) changes the stored query or answer instead. Underneath, a block is the graph runtime's *Block*: the run ends as a finished failure (`RunResult.Failed` with a `GuardrailBlocked` error, the thread's checkpoint `Failed`), which `recover` has nothing to continue and `start` accepts like a completed thread. A `Left` from another middleware's `beforeAgent` or `afterAgent` blocks the same way, but is returned as that `Left`. A list of guardrails runs completely: each guardrail, in order, on the value the previous one returned, **even after one has failed**. That is why the block carries every failure's error, and why a guardrail that costs a call - an LLM judge - runs on every turn that reaches its list. To stop at the first failure, use [`CompositeGuardrail.sequential`](#sequential-short-circuit) (adapted with `InputGuardrail.from`, see [Using a composite in an agent](#using-a-composite-in-an-agent)); the block then names the composite as the guardrail. Running the whole list is the settled default ([#1521](https://github.com/llm4s/llm4s/issues/1521)): it reports every reason a value was refused in one block, and an early stop is one composite away, while the opposite default would hide the other failures with no way to ask for them. A block sends one durable `agent.guardrail_blocked` event, naming the first failing guardrail and its phase (`Input` or `Output`), and no event comes from a guardrail that passed or that ran after the first failure. The run then ends with the kernel's `RunFailed`; `RunCompleted` is never sent for a blocked turn. An input block comes before any model call. An output block comes after the turn's `ModelCallCompleted` events, since the answer it refuses has been produced. See [Streaming](streaming) for the event stream. Guardrails on the root agent guard its whole handoff family: they apply to every turn's query and every final answer, whichever agent is active after a handoff. See [Guardrails and Middleware Across Handoffs](handoffs#guardrails-and-middleware-across-handoffs). --- ## Built-in Guardrails ### Simple Validators These guardrails run locally without LLM calls: | Guardrail | Purpose | Example | |-----------|---------|---------| | `LengthCheck` | Enforce min/max length | `new LengthCheck(1, 10000)` | | `ProfanityFilter` | Block profane content | `new ProfanityFilter()` | | `JSONValidator` | Ensure valid JSON output | `new JSONValidator()` | | `RegexValidator` | Pattern matching | `new RegexValidator("\\d{3}-\\d{4}")` | | `ToneValidator` | Simple tone detection | `new ToneValidator(Tone.Professional)` | | `PIIDetector` | Detect PII (email, SSN, etc.) | `new PIIDetector()` | | `PIIMasker` | Mask detected PII | `new PIIMasker()` | | `PromptInjectionDetector` | Detect injection attempts | `new PromptInjectionDetector()` | ### LLM-as-Judge Guardrails These use an LLM to evaluate subjective qualities: | Guardrail | Purpose | Example | |-----------|---------|---------| | `LLMSafetyGuardrail` | Content safety check | `new LLMSafetyGuardrail(client)` | | `LLMFactualityGuardrail` | Verify factual accuracy | `new LLMFactualityGuardrail(client)` | | `LLMQualityGuardrail` | Assess response quality | `new LLMQualityGuardrail(client)` | | `LLMToneGuardrail` | Validate tone compliance | `new LLMToneGuardrail(client, "professional")` | Each judge scores the content and passes it when the score reaches its `threshold`. The threshold must be between 0.0 and 1.0: for any other value, or NaN, `validate` returns a `ValidationError` on the field `threshold` without calling the judge. ### RAG-Specific Guardrails For retrieval-augmented generation: | Guardrail | Purpose | |-----------|---------| | `GroundingGuardrail` | Verify answers are grounded in retrieved context | | `ContextRelevanceGuardrail` | Check context relevance to query | | `SourceAttributionGuardrail` | Ensure sources are cited | | `TopicBoundaryGuardrail` | Prevent off-topic responses | --- ## Basic Usage ### Input Validation (not compile-checked) ```scala import org.llm4s.agent.{ Agent, AgentStatus } import org.llm4s.agent.graph.middleware.GuardrailMiddleware import org.llm4s.agent.guardrails.builtin._ val result = for { agent <- Agent.builder("assistant", client) .withTools(tools) .withMiddleware( GuardrailMiddleware( input = Seq( new LengthCheck(min = 1, max = 10000), new ProfanityFilter(), new PromptInjectionDetector() ), output = Nil ) ) .build() result <- agent.run(userInput) } yield result result match { case Right(r) => r.status match { case AgentStatus.Blocked(guardrail, reason) => println(s"Input rejected by $guardrail: $reason") case AgentStatus.Completed(answer) => println(answer) case other => println(s"Run ended: $other") } case Left(error) => println(s"Run failed: ${error.message}") } ``` ### Output Validation (not compile-checked) ```scala val agent = Agent.builder("assistant", client) .withTools(tools) .withMiddleware(GuardrailMiddleware(input = Nil, output = Seq(new JSONValidator(), new PIIMasker()))) .build() val result = agent.flatMap(_.run("Generate a JSON response with user data")) ``` ### Combined Input/Output (not compile-checked) ```scala val agent = Agent.builder("assistant", client) .withTools(tools) .withMiddleware( GuardrailMiddleware( input = Seq( new LengthCheck(1, 5000), new ProfanityFilter() ), output = Seq( new LLMSafetyGuardrail(client), new ToneValidator(Set(Tone.Professional)) ) ) ) .build() val result = agent.flatMap(_.run(userInput)) ``` --- ## LLM-as-Judge Examples ### Safety Check (not compile-checked) ```scala import org.llm4s.agent.guardrails.builtin.LLMSafetyGuardrail val safetyGuardrail = new LLMSafetyGuardrail(client) val agent = Agent.builder("storyteller", client) .withMiddleware(GuardrailMiddleware(input = Nil, output = Seq(safetyGuardrail))) .build() agent.flatMap(_.run("Write a story")) ``` ### Factuality Check Verify responses are grounded in source documents: (not compile-checked) ```scala import org.llm4s.agent.guardrails.builtin.LLMFactualityGuardrail val factualityGuardrail = LLMFactualityGuardrail.strict( client = client, referenceContext = "The capital of France is Paris. Paris has a population of 2.1 million." ) val agent = Agent.builder("geography", client) .withMiddleware(GuardrailMiddleware(input = Nil, output = Seq(factualityGuardrail))) .build() agent.flatMap(_.run("What is the capital of France?")) ``` ### Tone Validation (not compile-checked) ```scala import org.llm4s.agent.guardrails.builtin.LLMToneGuardrail val toneGuardrail = new LLMToneGuardrail( llmClient = client, allowedTones = Set("professional", "helpful") ) val agent = Agent.builder("support", client) .withMiddleware(GuardrailMiddleware(input = Nil, output = Seq(toneGuardrail))) .build() agent.flatMap(_.run("Help with customer complaint")) ``` --- ## Composite Guardrails Combine multiple guardrails with different strategies: ### All Must Pass (AND) (not compile-checked) ```scala import org.llm4s.agent.guardrails.CompositeGuardrail val strictValidation = CompositeGuardrail.all(Seq( new LengthCheck(1, 5000), new ProfanityFilter(), new PIIDetector() )) // All guardrails must pass for input to be accepted ``` ### Any Must Pass (OR) (not compile-checked) ```scala val flexibleValidation = CompositeGuardrail.any(Seq( new RegexValidator("^[A-Z].*"), // Starts with capital new RegexValidator("^\\d.*") // Starts with digit )) // At least one guardrail must pass ``` ### Sequential (Short-Circuit) (not compile-checked) ```scala val sequentialValidation = CompositeGuardrail.sequential(Seq( new LengthCheck(1, 10000), // Check length first new ProfanityFilter(), // Then profanity new PIIDetector() // Then PII )) // Stops at first failure, more efficient ``` ### Using a composite in an agent A composite is a `Guardrail[String]`, while `GuardrailMiddleware` takes `InputGuardrail`s and `OutputGuardrail`s, so adapt it with `InputGuardrail.from` or `OutputGuardrail.from`. A cast does not work: it throws a `ClassCastException`. (not compile-checked) ```scala import org.llm4s.agent.graph.middleware.GuardrailMiddleware import org.llm4s.agent.guardrails.InputGuardrail val agent = Agent .builder("assistant", client) .withMiddleware(GuardrailMiddleware(Seq(InputGuardrail.from(sequentialValidation)), Seq.empty)) .build() ``` `from` keeps the guardrail's name and description and passes the hook's context on, so an LLM judge inside the composite has its model calls counted in the thread's usage (a hand-written wrapper that overrides only `validate(value)` loses that). An `InputGuardrail` passed to `InputGuardrail.from` comes back unchanged. `all` runs every guardrail and reports every failure; `any` stops at the first guardrail that passes, so those after it do not run; `sequential` stops at the first failure. --- ## Custom Guardrails ### Basic Custom Guardrail (not compile-checked) ```scala import org.llm4s.agent.guardrails.InputGuardrail import org.llm4s.error.ValidationError import org.llm4s.types.Result class KeywordRequirementGuardrail(requiredKeywords: Set[String]) extends InputGuardrail { val name: String = "keyword-requirement" def validate(value: String): Result[String] = { val found = requiredKeywords.filter(kw => value.toLowerCase.contains(kw.toLowerCase)) if (found.nonEmpty) { Right(value) } else { Left(ValidationError.invalid( "input", s"Input must contain at least one of: ${requiredKeywords.mkString(", ")}" )) } } } // Usage val guardrail = new KeywordRequirementGuardrail(Set("scala", "java", "kotlin")) ``` ### Custom Output Guardrail (not compile-checked) ```scala import org.llm4s.agent.guardrails.OutputGuardrail import org.llm4s.error.ValidationError import org.llm4s.types.Result class MaxSentenceCountGuardrail(maxSentences: Int) extends OutputGuardrail { val name: String = "max-sentence-count" def validate(value: String): Result[String] = { val sentenceCount = value.split("[.!?]+").length if (sentenceCount <= maxSentences) { Right(value) } else { Left(ValidationError.invalid( "output", s"Response has $sentenceCount sentences, max allowed is $maxSentences" )) } } } ``` ### Custom LLM-Based Guardrail (not compile-checked) ```scala import org.llm4s.agent.guardrails.LLMGuardrail import org.llm4s.llmconnect.LLMClient class CustomLLMGuardrail(client: LLMClient) extends LLMGuardrail { val name: String = "custom-llm-check" val llmClient: LLMClient = client val evaluationPrompt: String = "Rate if this content is appropriate for a children's website (0-1)" override val threshold = 0.8 } ``` --- ## RAG Guardrails ### Basic RAG Setup (not compile-checked) ```scala import org.llm4s.agent.guardrails.rag._ val ragGuardrails = RAGGuardrails.standard(client) val agent = Agent.builder("rag", client) .withTools(tools) .withMiddleware(GuardrailMiddleware(ragGuardrails.inputGuardrails, ragGuardrails.outputGuardrails)) .build() agent.flatMap(_.run(question)) ``` ### Preset Configurations (not compile-checked) ```scala // Minimal - basic safety only val minimal = RAGGuardrails.minimal() // Standard - balanced for production val standard = RAGGuardrails.standard(client) // Strict - maximum safety val strict = RAGGuardrails.strict(client) // Monitoring - warn mode, doesn't block val monitoring = RAGGuardrails.monitoring(client) ``` ### Individual RAG Guardrails (not compile-checked) ```scala // Verify answer is grounded in retrieved context val grounding = new GroundingGuardrail( client = client, retrievedContext = retrievedDocuments ) // Check retrieved context is relevant to query val relevance = new ContextRelevanceGuardrail( client = client, query = userQuery ) // Ensure sources are properly cited val attribution = new SourceAttributionGuardrail( client = client, sourceDocuments = sources ) // Prevent off-topic responses val topicBoundary = new TopicBoundaryGuardrail( client = client, allowedTopics = Set("programming", "software engineering") ) ``` --- ## PII Detection and Masking ### Detect PII (not compile-checked) ```scala import org.llm4s.agent.guardrails.builtin.PIIDetector val piiDetector = new PIIDetector() // Detects: emails, SSNs, credit cards, phone numbers, etc. val agent = Agent.builder("assistant", client) .withTools(tools) .withMiddleware(GuardrailMiddleware(input = Seq(piiDetector), output = Nil)) .build() agent.flatMap(_.run(userInput)) ``` ### Mask PII in Output (not compile-checked) ```scala import org.llm4s.agent.guardrails.builtin.PIIMasker val piiMasker = new PIIMasker() // Replaces PII with [REDACTED_EMAIL], [REDACTED_SSN], etc. - the stored answer is the masked one val agent = Agent.builder("assistant", client) .withTools(tools) .withMiddleware(GuardrailMiddleware(input = Nil, output = Seq(piiMasker))) .build() agent.flatMap(_.run("Get user details")) ``` ### Supported PII Types | Type | Pattern | Masked As | |------|---------|-----------| | Email | `user@domain.com` | `[REDACTED_EMAIL]` | | SSN | `123-45-6789` | `[REDACTED_SSN]` | | Credit Card | `4111-1111-1111-1111`, `3782 822463 10005` (15-digit American Express) | `[REDACTED_CARD]` | | Phone | `(555) 123-4567`, `+44 20 7946 0958` | `[REDACTED_PHONE]` | | IP Address | `192.168.1.1` | `[REDACTED_IP]` | A phone number is a US number (with an optional `+1` or `1` prefix) or an international number that starts with `+` and has 8 to 15 digits, with spaces, dashes, dots or parentheses between them. Digits without a `+` are not treated as an international number. A card number is recognised by its shape (a Visa, MasterCard, Amex or Discover prefix and the right number of digits); the check digit is not validated. When two types match overlapping text (a plain card number is also account-shaped, and a phone number can run into the email address after it), the overlapping text is masked once, under the type whose match starts first. Detection does not merge them: `detect` returns one match per type, so `summarize` counts that text once for each type (`123456789` gives `SSN -> 1, Passport -> 1` with `PIIType.all`), and `PIIDetector`'s block message lists both types. A finding is one match of one type, so the sum of the counts is how often the patterns fired, not how many stretches of PII the text holds. --- ## Prompt Injection Protection (not compile-checked) ```scala import org.llm4s.agent.guardrails.builtin.PromptInjectionDetector val injectionDetector = new PromptInjectionDetector() val agent = Agent.builder("assistant", client) .withTools(tools) .withMiddleware(GuardrailMiddleware(input = Seq(injectionDetector), output = Nil)) .build() agent.flatMap(_.run(userInput)) // Detects patterns like: // - "Ignore previous instructions..." // - "System: You are now..." // - "---\nNew instructions:" // - Base64 encoded payloads ``` --- ## Error Handling ### Blocked Turns A block is an outcome, not an error: `run` returns `Right` with `AgentStatus.Blocked`, the blocked turn absent from `result.messages`, and the thread can take another turn. (not compile-checked) ```scala agent.run(query) match { case Right(result) => result.status match { case AgentStatus.Blocked(guardrail, reason) => println(s"Guardrail '$guardrail' blocked the turn: $reason") // Take appropriate action (rephrase, notify user, log); agent.continueConversation(result, ...) still works case AgentStatus.Completed(answer) => println(answer) case other => println(s"Run ended: $other") } case Left(error) => // a provider error, a tool's failure, a blank query, or another middleware's beforeAgent/afterAgent Left println(s"Run failed: ${error.message}") } ``` ### Validation Mode Control how guardrail results are handled: (not compile-checked) ```scala import org.llm4s.agent.guardrails.ValidationMode // Block on failure (default) val blocking = ValidationMode.Block // Warn only, continue processing val warn = ValidationMode.Warn // Log and continue val log = ValidationMode.Log ``` --- ## Best Practices ### 1. Layer Your Guardrails (not compile-checked) ```scala // Fast, local checks first val input = Seq( new LengthCheck(1, 10000), // Cheapest first new ProfanityFilter(), // Still fast new PromptInjectionDetector(), // Pattern matching new PIIDetector() // More complex but local ) // LLM checks for output only (expensive) val output = Seq( new JSONValidator(), // Fast, local new LLMSafetyGuardrail(client) // Expensive, last ) val agent = Agent.builder("assistant", client) .withMiddleware(GuardrailMiddleware(input, output)) .build() ``` ### 2. Use Appropriate Guardrails for Each Use Case | Use Case | Recommended Guardrails | |----------|------------------------| | Customer support | `ProfanityFilter`, `ToneValidator`, `LLMSafetyGuardrail` | | Code generation | `LengthCheck`, `JSONValidator` (for structured output) | | RAG application | `GroundingGuardrail`, `SourceAttributionGuardrail` | | Content moderation | `PIIDetector`, `ProfanityFilter`, `LLMSafetyGuardrail` | | Form processing | `RegexValidator`, `LengthCheck` | ### 3. Test Your Guardrails (not compile-checked) ```scala import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers class GuardrailSpec extends AnyFlatSpec with Matchers { "LengthCheck" should "reject empty input" in { val guardrail = new LengthCheck(min = 1, max = 100) guardrail.validate("") shouldBe a[Left[_, _]] } it should "accept valid input" in { val guardrail = new LengthCheck(min = 1, max = 100) guardrail.validate("Hello") shouldBe Right("Hello") } } ``` --- ## Examples | Example | Description | |---------|-------------| | [BasicInputValidationExample](/examples/#basic) | Length and profanity checks | | [JSONOutputValidationExample](/examples/#guardrails-examples) | JSON output validation | | [LLMJudgeGuardrailExample](/examples/#guardrails-examples) | LLM-as-Judge patterns | | [CompositeGuardrailExample](/examples/#composite) | Combining guardrails | | [CustomGuardrailExample](/examples/#custom) | Building custom validators | | [FactualityGuardrailExample](/examples/#guardrails-examples) | RAG factuality checking | [Browse all examples →](/examples/) --- ## Next Steps - [Memory Guide](memory) - Persistent context across conversations - [Handoffs Guide](handoffs) - Agent-to-agent delegation - [Streaming Guide](streaming) - Real-time execution events --- Source: https://llm4s.org/guide/agents/memory # Memory System Persistent context and knowledge for agents across conversations. --- ## Overview The LLM4S Memory System provides: - **Short-term memory** - Conversation context within a session - **Long-term memory** - Persistent facts and knowledge - **Semantic search** - Find relevant context using embeddings - **Entity tracking** - Remember information about people, places, things - **Multiple backends** - In-memory, SQLite, vector stores The snippets in this guide are kept in `MemoryGuideSpec` (`modules/memory/src/test/scala/org/llm4s/agent/memory/MemoryGuideSpec.scala`), which compiles them and checks what the text around them says. Snippets that need a live provider (an embedding client, an agent) take it as a parameter, so they are compiled but not run against one. The Postgres store lives in another module and is not covered there. --- ## Dependency (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-memory" % llm4sVersion ``` The Postgres backend is a separate artifact - see [Postgres Memory Store](../../reference/postgres-memory-store). Package names are unchanged either way, so `org.llm4s.agent.memory.*` imports keep working. --- ## Quick Start (not compile-checked) ```scala import org.llm4s.agent.memory._ val result = for { // Record a user fact m1 <- SimpleMemoryManager.empty.recordUserFact( fact = "Prefers Scala over Java", userId = Some("user-123"), importance = Some(0.9) ) // Record entity knowledge m2 <- m1.recordEntityFact( entityId = EntityId("anthropic"), entityName = "Anthropic", fact = "AI company that created Claude", importance = Some(0.8) ) // Get relevant context for a query context <- m2.getRelevantContext("Tell me about Scala programming") } yield context result match { case Right(ctx) => println(s"Context: $ctx") case Left(err) => println(s"Error: $err") } ``` Every write returns a `Result` holding the *updated* manager, so chain the calls and keep using the manager you get back. This prints: ``` # Retrieved Context ## User Preferences - Prefers Scala over Java ``` The in-memory store searches by **keyword**: it splits the query into phrases - its whitespace-separated pieces, each of whose words must appear next to each other and in order - and returns the memories that contain at least one of them as whole words, best first. A memory's score is the share of the query's distinct phrases it contains, not of its words. "Prefers Scala over Java" contains the phrase "scala" from the query, so it is returned; the Anthropic fact contains none, so it is not. A word never matches inside a longer one, so `i` matches the word "I" but not "Berlin", and `prefer` does not match "Prefers" - there is no stemming. Words are split and compared exactly as the SQLite stores' FTS5 index (`unicode61`) does, so both kinds of store find the same memories for a query ([#1594](https://github.com/llm4s/llm4s/issues/1594)): - A word is a run of letters, digits and private-use characters in any script. Anything else separates words: punctuation (`java?` is the word `java`), and also combining marks such as Devanagari vowel signs, so "मुझे" is the two words "म" and "झ". - Case is ignored, and so is the accent of a Latin letter that has exactly one (`ECOLE` matches `école`). Other accents are kept: `αθηνα` does not match "Αθήνα", `живет` does not match "Живёт", `мои` does not match "Мой", and `viet` does not match Vietnamese "Việt", whose `ệ` has two. - A query word with punctuation inside is a phrase: `berlin-based` matches "Berlin-based" but not "based in Berlin". The two kinds of store rank the memories they find differently (the SQLite store uses BM25). To retrieve by meaning, use a vector store (see [Vector Store](#vector-store)). --- ## Memory Types | Type | Purpose | Example | |------|---------|---------| | `Conversation` | Chat history | "User asked about weather" | | `UserFact` | User preferences/info | "Prefers dark mode" | | `Entity` | Knowledge about entities | "Paris is capital of France" | | `Knowledge` | External knowledge | "Scala 3 released in 2021" | | `Task` | Task outcomes | "Generated report successfully" | | `Custom(name)` | Application-specific | Any custom memory type | --- ## Memory Manager The `MemoryManager` is the main interface for working with memory. `SimpleMemoryManager` is the standard implementation; `LLMMemoryManager` adds LLM-powered consolidation and entity extraction (see [Memory Consolidation](#memory-consolidation) and [Entity Extraction](#entity-extraction)). (not compile-checked) ```scala import org.llm4s.agent.memory._ // In-memory store, default configuration val manager = SimpleMemoryManager.empty // A store and a configuration of your own val configured = SimpleMemoryManager.withStore( store = InMemoryStore.empty, config = MemoryManagerConfig(defaultImportance = 0.7) ) ``` ### Configuration Options `MemoryManagerConfig` is built in code (this module reads no configuration file or environment variable): `MemoryManagerConfig(...)` with any of the options below, or a preset (`MemoryManagerConfig.default`, `.testing`, `.fullFeatured`), changed with its `with*` setters (`withAutoExtractEntities`, `withDefaultImportance`, `withContextTokenBudget`, `withConsolidationConfig`). It has no public constructor or `copy`: | Option | Default | Description | |--------|---------|-------------| | `defaultImportance` | `0.5` | Importance given to a memory when you do not pass one | | `contextTokenBudget` | `2000` | Token budget of `getRelevantContext` when the call passes no `maxTokens` | | `autoExtractEntities` | `false` | `recordMessage` and `recordConversation` also run `extractEntities` over each user and assistant message | | `consolidationConfig` | `ConsolidationConfig.default` | Group size and error handling of `consolidateMemories` | Both `SimpleMemoryManager` and `LLMMemoryManager` read every option. An explicit `maxTokens` always wins over `contextTokenBudget`. `autoExtractEntities` only changes what `LLMMemoryManager` stores, because `SimpleMemoryManager.extractEntities` extracts nothing. Extraction is best-effort: if the call fails, the message stays recorded and a warning is logged. A cancelled call is not a failure: `recordMessage` returns the `CancelledError` (and `recordConversation` stops there), and an interrupted thread keeps its interrupt flag. > **`autoExtractEntities` costs one extra LLM call per recorded message.** With > `MemoryManagerConfig.fullFeatured`, or `autoExtractEntities = true`, an `LLMMemoryManager` sends one > entity-extraction request for every user and assistant message it records (system and tool > messages are not analysed), on top of your own calls. Recording a 20-turn conversation with > `recordConversation` is up to 20 more requests, paid for and waited on before it returns. Leave it > off (the default) and call `extractEntities` yourself where you want entities. (not compile-checked) ```scala val config = MemoryManagerConfig(contextTokenBudget = 500, autoExtractEntities = true) val tighter = config.withContextTokenBudget(250) ``` Managers record only what you pass to the `record*` methods, and nothing consolidates unless you call `consolidateMemories`: the `autoRecordMessages` and `consolidationEnabled` options, which no manager ever read, were removed in 0.5.0 ([#1579](https://github.com/llm4s/llm4s/issues/1579)). --- ## Recording Memory Every `record*` method returns `Result[MemoryManager]`: the manager with the memory added. ### User Facts (not compile-checked) ```scala // Record a user preference val result = manager.recordUserFact( fact = "Prefers functional programming", userId = Some("user-123"), importance = Some(0.9) ) ``` ### Entity Knowledge (not compile-checked) ```scala // Record knowledge about an entity val result = manager.recordEntityFact( entityId = EntityId("scala-lang"), entityName = "Scala", fact = "Scala is a JVM language combining OOP and FP", entityType = "language", importance = Some(0.8) ) ``` ### External Knowledge (not compile-checked) ```scala // Record external knowledge: `source` says where it came from val result = manager.recordKnowledge( content = "The latest LLM4S version is 0.5.0", source = "release-notes", metadata = Map("channel" -> "stable") ) ``` `recordKnowledge` takes no `importance`, and the memory it stores has none (the metadata you pass is kept on it). Use `recordEntityFact` or `recordUserFact` when importance matters. ### Task Outcomes (not compile-checked) ```scala // Record a task result val result = manager.recordTask( description = "Generate the quarterly report", outcome = "Report generated", success = true, importance = Some(0.6) ) ``` ### Conversation Messages (not compile-checked) ```scala import org.llm4s.llmconnect.model._ // Record a conversation turn val result = manager.recordMessage( message = UserMessage("What's the weather in Paris?"), conversationId = "conv-789" ) // Record several messages at once val result = manager.recordConversation( messages = Seq(UserMessage("And in Rome?"), AssistantMessage("Sunny, 24 degrees.")), conversationId = "conv-789" ) ``` The conversation id is a plain `String`. Each message is stored with its role (`user`, `assistant`, `system` or `tool`). --- ## Retrieving Memory Each retrieval method returns a `Result[String]`, ready to put in a prompt. When there is nothing to report the string is empty. ### Relevant Context Get the memories relevant to a query. With the in-memory store this is keyword matching; with a vector store it is similarity of meaning: (not compile-checked) ```scala val context = manager.getRelevantContext( query = "Tell me about Scala programming", maxTokens = 1000, filter = MemoryFilter.ByTypes(Set(MemoryType.UserFact, MemoryType.Knowledge)) ) ``` The context starts with `# Retrieved Context` and groups the memories under `## Relevant Knowledge`, `## Entity Information`, `## User Preferences`, `## Previous Context` and `## Past Tasks`. `maxTokens` limits the length at about four characters per token (see [Manage Context Token Budget](#3-manage-context-token-budget)). ### Conversation History (not compile-checked) ```scala val history = manager.getConversationContext( conversationId = "conv-789", maxMessages = 10 ) ``` The result starts with `Previous conversation:` and has one `[role]: text` line per message. ### Entity Context (not compile-checked) ```scala val entityInfo = manager.getEntityContext(EntityId("anthropic")) ``` The result is `Known facts about :` followed by one `- fact` line per memory. ### User Context (not compile-checked) ```scala val userInfo = manager.getUserContext(Some("user-123")) ``` The result is `Known facts about the user:` followed by one `- fact` line per fact recorded for that user. Pass `None` to include the facts of every user. --- ## Memory Stores ### In-Memory Store Fast but volatile - loses data on restart. Each store is an immutable value: a write returns a new store, and `SimpleMemoryManager` keeps track of it for you. (not compile-checked) ```scala import org.llm4s.agent.memory.InMemoryStore val store = InMemoryStore.empty val manager = SimpleMemoryManager.withStore(store) ``` ### SQLite Store Persistent local storage. Opening a store returns a `Result`, and a SQLite store holds one JDBC connection, so close it when you are done: (not compile-checked) ```scala import org.llm4s.agent.memory.SQLiteMemoryStore for { // File-based (persistent) file <- SQLiteMemoryStore("memory.db") // In-memory SQLite (fast, volatile) volatile <- SQLiteMemoryStore.inMemory() } yield (file, volatile) ``` A SQLite store is not safe for concurrent use: give each thread its own instance, or synchronise access yourself. ### Vector Store Semantic search with embeddings: (not compile-checked) ```scala import org.llm4s.agent.memory._ import org.llm4s.llmconnect.EmbeddingClient import org.llm4s.llmconnect.config.EmbeddingModelConfig import org.llm4s.types.Result // Wrap an embedding client def embeddingServiceFor(embeddingClient: EmbeddingClient, modelConfig: EmbeddingModelConfig): EmbeddingService = LLMEmbeddingService(embeddingClient, modelConfig) // A vector store in a SQLite file (VectorMemoryStore.inMemory(embeddingService) keeps it in memory) def vectorManager(embeddingService: EmbeddingService, path: String): Result[SimpleMemoryManager] = VectorMemoryStore(path, embeddingService).map(store => SimpleMemoryManager.withStore(store)) ``` For tests, `MockEmbeddingService(dimensions = 8)` produces deterministic vectors from a hash of the text; its scores say nothing about meaning. The store embeds what it keeps as a *document* and the text you search with as a *query* (`EmbeddingService.embedQuery`). Models that embed the two differently (Voyage, Cohere, Jina) are then searched correctly. `embedQuery` delegates to `embed` unless a service overrides it, so a custom `EmbeddingService` written before it existed keeps working unchanged. --- ## Memory with Agents ### Semantic Memory Middleware (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent-memory" % llm4sVersion // same version as llm4s-agent ``` `SemanticMemoryMiddleware` (`org.llm4s.agent.harness.memory`) does the recall and the recording for you, for every turn of an agent: (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.agent.graph.{ RunConfig, TenantId, ThreadId } import org.llm4s.agent.harness.memory.{ MemoryRecall, SemanticMemoryMiddleware } import org.llm4s.agent.memory.{ MemoryBounds, SQLiteMemoryStore, SimpleMemoryManager } val memory = new SemanticMemoryMiddleware( stores = tenant => SQLiteMemoryStore(s"memory-${tenant.fold("default")(_.value)}.db"), manager = (store, _) => SimpleMemoryManager.withStore(store), client = client, // the client an LLMMemoryManager would call recall = Some(MemoryRecall(maxRecallTokens = 512)), bounds = MemoryBounds(maxMemories = 500) ) for { agent <- Agent.builder("assistant", client).withMiddleware(memory).build() result <- agent.run(ThreadId("t-1"), "What did I say my favourite colour was?", RunConfig().withTenantId(TenantId("alice"))) } yield result.answer ``` - **Per tenant.** `stores` is asked once for each tenant (`RunConfig.tenantId`; `None` for runs without one) and must return a store of that tenant's alone: every thread of a tenant recalls what the tenant's earlier threads recorded, and no tenant reads another's. A resolver that returns one store instance for two tenants is refused. - **Recall** runs `getRelevantContext` on each turn's input and shows the result to every model call of the turn as a system-prompt section, cut to `maxRecallTokens` (four characters a token). - **Recording** stores each turn's question and answer through the manager, each memory cut to `MemoryRecording.maxRecordChars` (default 2000). With an `LLMMemoryManager`, its own model calls (entity extraction, summaries) go through the run's accounted client, so they count in `result.usage`. - **Consolidation** (`consolidate = Some(MemoryConsolidation(olderThan = 1.day, minCount = 10))`) replaces groups of old memories with a summary after each turn. It needs a `TransactionalMemoryStore`: `InMemoryStore`, `SQLiteMemoryStore` or `PostgresMemoryStore`. - **Bounded.** Each write evicts the memories of lowest importance, oldest first, beyond `bounds` (default 1000 memories, 4 MiB), whether or not consolidation is on. - **Safe across a crash.** Each recording and each consolidated group is one replayable effect of the agent runtime: after a crash, `agent.recover` neither records a turn twice nor brings back a memory a later consolidation removed. A store that is not transactional records too, but a recording interrupted mid-write is skipped on recovery rather than repeated. - **Failures never cost the answer.** A store that cannot be searched or written is skipped, and announced with a durable `MemoryEvents.Skipped` (the operation and the error's type, no content); `MemoryEvents.Recorded` and `MemoryEvents.Consolidated` carry counts only. - **Register it before your guardrails** (or pass `runsBefore = Set(MiddlewareId("guardrails"))`), so an answer a guardrail blocks is never recorded. A recording that already applied is not undone by a later Block. - **Adding it to an existing agent** registers the `semantic-memory-recalled` key, which starts empty: a thread at rest continues under the agent that added it, and, since the recall is cleared at the end of each turn, under an agent that removed it again. A thread in flight (suspended, or after a crash) is refused with `RestoreRejected` by the changed agent: resume or recover it first. ### Injecting Context To do it by hand instead, without the middleware: Put the relevant memory in the system prompt, run the agent, then record the turn. The memory half of this is runnable as is; `runAgent` stands for however you build and call your agent (see the [Agents guide](index)), taking the system prompt and returning the messages of the turn: (not compile-checked) ```scala import org.llm4s.agent.memory._ import org.llm4s.llmconnect.model.Message import org.llm4s.types.Result def answerWithMemory( manager: MemoryManager, userQuery: String, conversationId: String )(runAgent: String => Result[Seq[Message]]): Result[MemoryManager] = for { // Get relevant context for the query context <- manager.getRelevantContext(userQuery) // Build a system prompt that carries the context systemPrompt = s"""You are a helpful assistant. | |Relevant context from memory: |$context""".stripMargin // Run your agent with that prompt and keep what was said messages <- runAgent(systemPrompt) updated <- manager.recordConversation(messages, conversationId) } yield updated ``` ### Recording the Conversation Managers record only what you pass to the `record*` methods, so record each turn yourself, as above, with `recordMessage` or `recordConversation`. --- ## Semantic Search ### With Embeddings (not compile-checked) ```scala import org.llm4s.agent.memory._ import scala.util.Using def semanticSearch(embeddingService: EmbeddingService): Result[Seq[ScoredMemory]] = VectorMemoryStore.inMemory(embeddingService).flatMap { store => Using.resource(new AutoCloseable { override def close(): Unit = store.close() }) { _ => for { m1 <- SimpleMemoryManager.withStore(store).recordKnowledge("Paris is the capital of France", "geography") m2 <- m1.recordKnowledge("Berlin is the capital of Germany", "geography") m3 <- m2.recordKnowledge("Rome is the capital of Italy", "geography") results <- m3.store.search("European capitals", topK = 2) } yield results } } ``` The results are `ScoredMemory` values, best first. With a real embedding model they are the memories closest in meaning to the query. ### Search with Filters Filters are built from the `MemoryFilter` cases and combined with `&&`, `||` and `!`: (not compile-checked) ```scala val filter = MemoryFilter.ByType(MemoryType.Knowledge) && MemoryFilter.MinImportance(0.7) && MemoryFilter.ByMetadata("user_id", "user-123") val results = store.search( query = "programming languages", topK = 5, filter = filter ) ``` The other cases are `ByTypes`, `HasMetadata`, `MetadataContains`, `ByEntity`, `ByConversation`, `ByTimeRange`, `ContentContains`, `Custom` (any `Memory => Boolean`), `All` and `None`. --- ## Memory Consolidation Consolidation summarises old memories into fewer, shorter ones. Only `LLMMemoryManager` does it: `SimpleMemoryManager.consolidateMemories` returns the manager unchanged. (not compile-checked) ```scala import org.llm4s.agent.memory._ import org.llm4s.llmconnect.LLMClient import org.llm4s.types.Result import java.time.Instant import java.time.temporal.ChronoUnit // An LLM client is needed to write the summaries def llmManager(client: LLMClient): LLMMemoryManager = LLMMemoryManager( config = MemoryManagerConfig.default, store = InMemoryStore.empty, client = client ) // Consolidate memories older than a week, in groups of at least ten def consolidate(manager: MemoryManager): Result[MemoryManager] = manager.consolidateMemories( olderThan = Instant.now().minus(7, ChronoUnit.DAYS), minCount = 10 ) ``` `minCount` applies to each group of old memories (same type and context) separately, not to their total. Nothing consolidates on its own: call `consolidateMemories` when you want it (for example from a scheduled job). --- ## Entity Extraction Extract entities from text using an LLM. Only `LLMMemoryManager` does it: on `SimpleMemoryManager`, `extractEntities` returns the manager unchanged. (not compile-checked) ```scala import org.llm4s.agent.memory._ import org.llm4s.types.Result def extractEntities(manager: MemoryManager): Result[String] = for { m1 <- manager.extractEntities( text = "Anthropic is an AI company that created Claude", conversationId = Some("conv-789") ) // The extracted entities are ordinary entity memories info <- m1.getEntityContext(EntityId.fromName("Anthropic")) } yield info ``` `extractEntities` returns the manager holding the new entity memories, not a list of ids. Read them back with `getEntityContext`; `EntityId.fromName` turns a name into the id used for it (lower case, spaces replaced by `_`). To mine every recorded user and assistant message instead, set `MemoryManagerConfig(autoExtractEntities = true)`: `recordMessage` then calls `extractEntities` for you, with the message's conversation id. --- ## Memory Statistics (not compile-checked) ```scala val result = manager.stats.map { stats => s"""Total memories: ${stats.totalMemories} |By type: ${stats.byType} |Oldest: ${stats.oldestMemory} |Newest: ${stats.newestMemory}""".stripMargin } ``` `stats` is a `Result[MemoryStats]`. It also has `entityCount`, `conversationCount` (the number of distinct conversations) and `embeddedCount`. `byType` leaves out types with no memories, and `oldestMemory` and `newestMemory` are `None` when there are none. --- ## Persistence Patterns ### Save and Load (not compile-checked) ```scala import scala.util.Using // Both opened stores close even when a Result is Left. val result = SQLiteMemoryStore("memory.db").flatMap { store => Using.resource(new AutoCloseable { override def close(): Unit = store.close() }) { _ => SimpleMemoryManager.withStore(store).recordUserFact("Likes Scala", Some("user-1")).map(_ => ()) } }.flatMap { _ => SQLiteMemoryStore("memory.db").flatMap { reopened => Using.resource(new AutoCloseable { override def close(): Unit = reopened.close() }) { _ => SimpleMemoryManager.withStore(reopened).getUserContext(Some("user-1")) } } } // Right("Known facts about the user:\n- Likes Scala") ``` ### Cross-Session Memory (not compile-checked) ```scala import scala.util.Using class PersistentMemory(dbPath: String) { // Open the store, run something against a manager on it, and close the store again def withManager[A](use: MemoryManager => Result[A]): Result[A] = SQLiteMemoryStore(dbPath).flatMap { store => Using.resource(new AutoCloseable { override def close(): Unit = store.close() }) { _ => use(SimpleMemoryManager.withStore(store)) } } } val memory = new PersistentMemory("memory.db") // Session 1 val result1 = memory.withManager(_.recordUserFact("Likes Scala", Some("user-1"))) // Session 2 (later) val result2 = memory.withManager(_.getUserContext(Some("user-1"))) // context includes "Likes Scala" from session 1 ``` --- ## Best Practices ### 1. Set Appropriate Importance (not compile-checked) ```scala for { // High importance - core user preferences m1 <- manager.recordUserFact("Primary programming language is Scala", importance = Some(0.9)) // Medium importance - useful but not critical m2 <- m1.recordEntityFact(EntityId("scaladays"), "ScalaDays", "Attended in 2024", importance = Some(0.6)) // Low importance - ephemeral information m3 <- m2.recordTask("Run the tests", "Passed", success = true, importance = Some(0.3)) } yield m3 ``` ### 2. Use Specific Memory Types Don't use generic `Knowledge` for everything: specific types are grouped under their own heading in the retrieved context and can be filtered with `MemoryFilter.ByType`. - `recordUserFact(...)` for user preferences - `recordEntityFact(...)` for entity-specific information - `recordKnowledge(...)` for external knowledge - `recordTask(...)` for task outcomes ### 3. Manage Context Token Budget (not compile-checked) ```scala // About four characters per token: the context is cut to fit val context = manager.getRelevantContext(query, maxTokens = 500) ``` `getRelevantContext` stops adding memories once the next one would take the text past `maxTokens * 4` characters; the section headings and the `# Retrieved Context` line count towards that. A heading is written only with at least one memory under it, so a `maxTokens` too small for any memory gives an empty string. Without `maxTokens` the budget is the manager's `MemoryManagerConfig.contextTokenBudget`, 2000 tokens unless you set it. ### 4. Clean Up Old Memories (not compile-checked) ```scala // Consolidate old memories periodically (LLMMemoryManager only) consolidate(manager) // Delete old, low-importance memories (there is no "maximum importance" filter: use `Custom`) store.deleteMatching( MemoryFilter.Custom(_.importance.exists(_ <= 0.3)) && MemoryFilter.ByTimeRange(before = Some(thirtyDaysAgo)) ) ``` `deleteMatching` returns the store without the matching memories. --- ## Examples | Example | Description | |---------|-------------| | [BasicMemoryExample](/examples/#memory-examples) | Getting started with memory | | [ConversationMemoryExample](/examples/#memory-examples) | Conversation history management | | [MemoryWithAgentExample](/examples/#memory-examples) | Integrating memory with agents | | [SQLiteMemoryExample](/examples/#memory-examples) | Persistent SQLite storage | | [VectorMemoryExample](/examples/#memory-examples) | Semantic search with embeddings | [Browse all examples →](/examples/) --- ## Next Steps - [Handoffs Guide](handoffs) - Agent-to-agent delegation - [Streaming Guide](streaming) - Real-time execution events - [Guardrails Guide](guardrails) - Input/output validation --- Source: https://llm4s.org/guide/agents/handoffs # Agent Handoffs Delegate queries to specialist agents for domain expertise. --- ## Overview Handoffs enable LLM-driven agent-to-agent delegation. When a primary agent determines that a query requires specialist expertise, it can hand off the conversation to another agent. A handoff is a route inside one graph. The root agent and every agent reachable through its handoffs are compiled together, by agent id, and one thread runs across them: the thread records which agent is active, and the next turn starts with that agent. **Key Benefits:** - **Specialization** - Route queries to domain experts - **Modularity** - Build focused, maintainable agents - **Scalability** - Add specialists without modifying the main agent - **Context Control** - Choose what context to preserve --- ## Quick Start ```scala import org.llm4s.agent.{Agent, Handoff} import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService val result = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) // The specialist is a builder, not yet a built agent mathAgent = Agent.builder("math", client) .withSystemPrompt("You are a math expert. Solve problems step-by-step.") // The main agent lists it as a handoff target mainAgent <- Agent.builder("main", client) .withHandoffs(Handoff.to("math", mathAgent, "Math expertise required")) .build() result <- mainAgent.run("What is the integral of x^2?") } yield result ``` The handoff id must equal the target builder's agent id (`"math"` above): the graph is keyed by agent id. --- ## Handoff Configuration ### Basic Handoff ```scala import org.llm4s.agent.Handoff val handoff = Handoff.to( "specialist", // equals the target builder's id specialistBuilder, "Domain expertise required" ) ``` `Handoff.of(id, builder, reason)` returns a `Result[Handoff]` instead of throwing for an invalid id (`[a-zA-Z0-9_-]{1,52}`). ### Handing Back (Cycles) A target that must hand back to an agent that is already in the graph names it by id, with `Handoff.toId`, because two builders cannot each hold the other: ```scala val specialist = Agent.builder("specialist", client) .withHandoffs(Handoff.toId("triage", "Not a specialist question")) val triage = Agent.builder("triage", client) .withHandoffs(Handoff.to("specialist", specialist, "Needs the specialist")) .build() ``` `build()` refuses an id that no builder in the family defines, and one id reached with two different builders. A self-handoff is refused too. ### Without Context ```scala // The specialist is sent the user's last question, then the transfer onwards val handoff = Handoff.to("specialist", specialistBuilder, "Fresh analysis needed", preserveContext = false) ``` `Handoff.of(id, builder, reason, preserveContext = false)` takes it too, and, for an agent defined elsewhere in the family, `Handoff.toId("specialist", Some("Fresh analysis needed"), preserveContext = false)`. --- ## Handoff Options | Option | Default | Description | |--------|---------|-------------| | `id` | Required | The target agent's id; the tool is `handoff_to_` | | `target` | by `to`/`of`: the builder | The target's builder; `None` (`toId`) names an agent defined elsewhere in the family | | `transferReason` | `None` | Description shown to the LLM for routing | | `preserveContext` | `true` | `false` sends the target only the last user message before the transfer, then the transfer onwards | `transferSystemMessage` no longer exists: each agent sends its own system prompt, and a prompt is never stored in the thread. --- ## Multiple Handoffs Provide multiple specialists for intelligent routing: ```scala val mainAgent = Agent.builder("main", client) .withHandoffs( Handoff.to("math", mathAgent, "Mathematical calculations and proofs"), Handoff.to("code", codeAgent, "Programming and code review"), Handoff.to("legal", legalAgent, "Legal questions and contracts"), Handoff.to("medical", medicalAgent, "Health and medical information") ) .build() ``` The LLM sees descriptions and chooses the appropriate specialist. --- ## How Handoffs Work ### 1. Tools are Generated Each handoff target is offered to the model as a tool named `handoff_to_`, described by the transfer reason. ### 2. LLM Decides The LLM can choose to: - Answer directly (no handoff) - Call a regular tool - Hand off by calling a handoff tool A handoff must be the **only** tool call in its message. A handoff mixed with other tool calls, or two handoffs in one message, is refused: every call in the message gets an error result explaining the rule, and the same agent's model is asked again. ### 3. The Transfer The loop stores the assistant message, appends a tool result `Transferred to `, makes the target the active agent and continues in the target's model node. The target sends its own system prompt and offers its own tools and handoffs. The handoff counts as a step, and the step limit is shared by the whole turn: it is the current agent's `maxSteps` against the turn's total step count. ### 4. Response Returns The target's answer is the run's result. `AgentResult.activeAgent` names the agent that answered, and the next turn starts with it, so a conversation that moved to a specialist stays there until the specialist hands it back. --- ## Specialist Agent Patterns ### Domain Expert ```scala val physicsAgent = Agent.builder("physics", client) .withSystemPrompt( """You are a physics expert with deep knowledge of: |- Classical mechanics |- Quantum mechanics |- Thermodynamics |- Electromagnetism | |Provide detailed explanations with equations when helpful.""".stripMargin ) val mainAgent = Agent.builder("main", client) .withHandoffs(Handoff.to("physics", physicsAgent, "Physics questions requiring expert explanation")) .build() mainAgent.flatMap(_.run("Explain quantum entanglement")) ``` ### Tool Specialist Tools belong to the agent that uses them: ```scala val dataAgent = Agent.builder("data", client) .withSystemPrompt("You are a data analysis specialist.") .withTools(new ToolRegistry(Seq(queryDatabaseTool, generateChartTool, exportCSVTool))) val mainAgent = Agent.builder("main", client) .withTools(new ToolRegistry(basicTools)) .withHandoffs(Handoff.to("data", dataAgent, "Data analysis with database access")) .build() mainAgent.flatMap(_.run("Analyze our Q4 sales data")) ``` ### Customer Support Triage ```scala val billingAgent = Agent.builder("billing", client) .withSystemPrompt("You handle billing and payment issues.") val technicalAgent = Agent.builder("technical", client) .withSystemPrompt("You solve technical problems.") val salesAgent = Agent.builder("sales", client) .withSystemPrompt("You help with purchases and upgrades.") val triageAgent = Agent.builder("triage", client) .withSystemPrompt("You are a customer support triage agent. Route queries to the appropriate specialist.") .withHandoffs( Handoff.to("billing", billingAgent, "Billing, payments, and subscription issues"), Handoff.to("technical", technicalAgent, "Technical problems and account access"), Handoff.to("sales", salesAgent, "Purchases, upgrades, and pricing questions") ) .build() triageAgent.flatMap(_.run("I can't log into my account and my payment failed")) ``` --- ## Guardrails and Middleware Across Handoffs The root agent's run-boundary middleware guards the whole family. Its `beforeAgent` hooks - input guardrails among them - run on every turn's query, even when a specialist is active, before the specialist's own; its `afterAgent` hooks - output guardrails - run on every final answer, after the answering specialist's own. So guardrails given to the root keep applying after a handoff: ```scala import org.llm4s.agent.graph.middleware.GuardrailMiddleware import org.llm4s.agent.guardrails.builtin.{ PIIMasker, ProfanityFilter } val triage = Agent.builder("triage", client) .withMiddleware(GuardrailMiddleware(input = Seq(new ProfanityFilter()), output = Seq(new PIIMasker()))) .withHandoffs(Handoff.to("billing", billingAgent, "Billing questions")) .build() // a query to billing two turns later still passes ProfanityFilter; billing's answers still pass PIIMasker ``` A specialist's own boundary middleware applies only while it is active, inside the root's. A block from either ends the turn `Blocked`; an output block removes the whole turn, a handoff made in it included, so the next turn starts with the agent that was active before it. Model and tool wrappers (`wrapModelCall`, `wrapToolCall`, such as `ContextWindowMiddleware` and `ApprovalMiddleware`) are per agent: give them to each agent that needs them. Do not give the root's guardrails to its specialists too: they would run twice. --- ## Handling Handoff Results ```scala mainAgent.run(query) match { case Right(result) => result.status match { case AgentStatus.Completed(answer) => println(s"${result.activeAgent} answered: $answer") case AgentStatus.StepLimitReached => println("Hit the step limit") case other => println(s"Run ended: $other") } case Left(error) => println(s"Error: $error") } ``` A handoff is not a status: the run continues in the target and ends as any run does. A handoff is reported by the durable `AgentEvents.HandedOff(from, to)` event on the agent's event stream (the old `HandoffStarted` / `HandoffCompleted`); see [Streaming Events](streaming). --- ## Context Preservation Examples ### Full Context (Default) ```scala // Specialist sees the entire conversation Handoff.to("specialist", specialist, "Needs the specialist") // User: "I'm building a Scala app" // Assistant: "Great! What kind of app?" // User: "A REST API with database access" // // Specialist sees all messages above, then its own reply ``` ### Fresh Context ```scala // Specialist starts from the last question Handoff.toId("specialist", Some("Independent review"), preserveContext = false) // User: "I'm building a Scala app" // Assistant: "Great! What kind of app?" // User: "A REST API with database access" // // Specialist is sent: "A REST API with database access", then the transfer ``` The stored history is the same either way: `preserveContext = false` changes only what the target is sent, and the transfers are recorded by the loop, so a non-root agent is never sent the full history by mistake. --- ## Handoffs vs Graphs | Use Case | Approach | |----------|----------| | 2-3 specialists, LLM-driven routing | **Handoffs** | | Complex multi-agent workflows | **Graph (`GraphBuilder`)** | | Dynamic specialist selection | **Handoffs** | | Parallel agent execution | **Graph** | | Simple delegation | **Handoffs** | | Type-safe data flow | **Graph** | To hand a bounded task to another agent on a thread of its own, keeping control and getting back only its typed report, use [delegation](delegation) instead. For complex workflows, see the [multi-agent graph recipe](../../examples/cookbook/multi-agent-graph.html): agents as nodes of a typed graph, run in parallel and joined. --- ## Best Practices ### 1. Clear Transfer Reasons ```scala // Good - specific and actionable Handoff.to("db", agent, "Database queries and SQL optimization") // Bad - vague Handoff.to("db", agent, "Technical stuff") ``` ### 2. Focused Specialists ```scala // Good - focused specialist val sqlAgent = Agent.builder("sql", client) .withSystemPrompt("You are a SQL expert. Optimize queries and explain execution plans.") // Bad - too broad val everythingAgent = Agent.builder("everything", client) .withSystemPrompt("You know everything about databases, APIs, UI, and infrastructure.") ``` ### 3. Changing a Family Changes Its Threads The graph's version covers each agent's id, tools, middleware, handoffs and system prompt. If you rebuild the root without a handoff that a stored thread's active agent was, `run` refuses that thread rather than routing to a missing node. ### 4. Don't Overuse Handoffs ```scala // Good - meaningful specialization .withHandoffs( Handoff.to("math", mathAgent, "Complex mathematical calculations"), Handoff.to("legal", legalAgent, "Legal analysis and compliance") ) // Bad - too granular .withHandoffs( Handoff.to("addition", additionAgent, "Adding numbers"), Handoff.to("subtraction", subtractionAgent, "Subtracting numbers") ) ``` --- ## Examples | Example | Description | |---------|-------------| | [SimpleTriageHandoffExample](/examples/#handoff-examples) | Basic query routing | | [MathSpecialistHandoffExample](/examples/#handoff-examples) | Math specialist delegation | | [ContextPreservationExample](/examples/#handoff-examples) | Context preservation patterns | [Browse all examples →](/examples/) --- ## Next Steps - [Streaming Guide](streaming) - Real-time execution events - [Memory Guide](memory) - Persistent context - [Guardrails Guide](guardrails) - Input/output validation --- Source: https://llm4s.org/guide/agents/streaming # Streaming Events Watch an agent's turn as it happens: the answer token by token, each tool call, each model call. --- ## Streaming a turn `AgentBuilder.withStreaming()` makes every model call of the agent stream its answer (the default is off: the agent calls `complete`). `agent.stream(threadId, query)(listener)` runs a turn and hands every event of it to `listener`, which receives the runtime's `StreamEvent`. Match the agent's events with the extractors in `AgentEvents`; they need no `ujson`: (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.agent.events.AgentEvents import org.llm4s.agent.graph.ThreadId for agent <- Agent.builder("assistant", client).withSystemPrompt("You are concise.").withStreaming().build() run <- agent.stream(ThreadId("t1"), "Explain monads in three sentences.") { case AgentEvents.TextDelta(d) => print(d.text) case AgentEvents.ToolCallStarted(c) => println(s"\n[${c.tool}]") case AgentEvents.ModelCallCompleted(m) => println(s"\n(${m.usage})") case _ => () } result <- run.await() yield result ``` `stream` returns the `AgentRun` at once; `await()` gives the `AgentResult` as for `run`, and returns only once the listener has returned from the run's last event - so everything the listener collected is there when `await` returns. It waits at most 5 seconds for a listener that is still busy, then logs a WARN and returns. `streamResume(threadId, answers)` and `streamRecover(threadId)` are the streaming siblings of `startResume` and `startRecover`. A listener runs on its own dispatcher thread, so a slow listener never slows the run. Runnable version: `StreamingAgentExample` in `modules/samples`. ## The events Every event is an `EventType` in `org.llm4s.agent.events.AgentEvents`, named `agent.`, version 1. **Durable** events are committed with the task that emits them, so they are stored, replayed, and never duplicated by a retry or a `recover`. They carry no message content. | Event | Fields | Sent | |---|---|---| | `ModelCallCompleted` | `agent`, `model`, `attempts`, `toolCalls`, `usage`, `estimatedCost` | after each provider call returns a completion - every attempt a retrying wrapper makes, numbered in `attempts`, and (`attempts` 0) each call a hook makes through `ModelContext.accounted` | | `ToolExecuted` | `agent`, `toolCallId`, `tool`, `duration`, `outcome` (`Succeeded`, `Errored`, `Denied`, `Rejected`, `NeedsApproval`, `Asked`) | when a tool call's outcome is recorded | | `HandedOff` | `from`, `to` | when the model routes a handoff | | `GuardrailBlocked` | `guardrail`, `phase` (`Input`, `Output`) | when a guardrail blocks a turn | **Live** events are for watching. They carry content, have no `seq`, and are never stored or replayed. | Event | Fields | Sent | |---|---|---| | `ModelCallStarted` | `agent`, `attempt` | before each attempt of a model call | | `TextDelta` | `attempt`, `text` | per chunk of the answer (needs `withStreaming()`) | | `ThinkingDelta` | `attempt`, `text` | per chunk of the model's reasoning (needs `withStreaming()`) | | `ToolCallStarted` | `toolCallId`, `tool`, `arguments` | after the arguments validate, before middleware runs | | `ToolCallResult` | `toolCallId`, `content`, `isError` | when the call's result is recorded | Notes on the durable events: - `attempts` is 0 when a model middleware answered without calling the model. - `ToolExecuted.tool` names the tool only when the agent has it; a call to a name the model invented is recorded as `""` (the live `ToolCallResult` still carries the call). Each non-handoff call of a batch that mixes a handoff with other calls runs nothing and is reported as `Errored`. - A tool call that is approved or edited produces two `agent.tool_executed` events for one call id, across two runs: `NeedsApproval` when it suspends, then the final outcome after `resume`. - `agent.guardrail_blocked` fires only for a guardrail's block. Other middleware refusals (a blank query or answer, a custom `Left`) emit none. - The kernel's own events (`RunStarted`, `RunCompleted`, `RunFailed`, ...) arrive in the same stream, as `StreamEvent.Durable`. ## Durable vs live, and the content rule Durable events hold identifiers, names, usage, durations and outcomes, never message content: no assistant text, thinking, tool arguments, tool results or guardrail reasons. Content travels only in live events, and in `AgentRunEnded.messages` at the end of a traced run. The reason is the guardrail Block guarantee. When an output guardrail blocks a turn, the runtime removes the blocked turn from the thread. If the log held the content, a Block would also have to redact the log. Replaying a run therefore shows its structure (which model calls, which tools, how long, with what usage), and a blocked answer is in no stored event. ## Attempts `attempt` counts the calls of the innermost model function within one task, from 1. A model wrapper that retries or falls back (`wrapModelCall`) calls it again, so a higher `attempt` on the same task means the text streamed so far for that task is discarded: clear what you have shown and start again. A task that `recover` runs again starts at attempt 1 under a new task id. ## Subagents' events An agent with subagents sends `SubagentEvents.TaskStarted` (live) as each child starts and `SubagentEvents.TaskEnded` (durable, no content) when its task ends. With `nested = true` on `stream*` or `AgentRun.subscribe`, the listener also receives each child's events as `StreamEvent.Nested(child, event)`, matched with `EventHandlers.onChild`; see [Delegation: following children](delegation#following-children). ## `stream*` vs `subscribe` - `Agent.stream`, `streamResume` and `streamRecover` subscribe your listener during admission, before the run thread starts, so it sees every durable and every live event of the run, from the claim on. - `AgentRun.subscribe(capacity = 1024)(listener)` attaches later, to a run you started with `start`. It replays the run's durable events from its start, then delivers new ones, but it misses live events sent before it attached. Live events sent after it returns are not lost while it replays: they are held and delivered in their place among the durable events. Up to `capacity` are held; beyond that they are dropped and counted, as for a full queue, and the listener gets a `LiveGap(dropped)` where they would have been. Both are run-scoped: a listener sees only this run's events, even when other runs share the thread, and ends after the run's terminal event (`RunCompleted`, `RunSuspended`, `RunFailed`, `RunCancelled`, `RunTimedOut`), or after a `Disconnected` (`Lagging`, `ListenerFailed`, or - for `subscribe`, which replays - `ReplayFailed`). A run that crashes without a terminal event (or whose terminal commit fails) ends its stream as soon as the listener has returned from the run's last event: as the run ends, a marker is queued behind its last event, and reaching it ends the subscription - also for a `subscribe` made after the run ended, once its replay is done. There is no fixed delay. Either way, `await` waits for the listener as described above. ## Falling behind Each listener has a bounded buffer (1024 events for `stream*`) for durable events and another of the same size for live ones, so a burst of live events never costs it a durable one: - If the live buffer is full, live events are dropped, and the listener gets `StreamEvent.LiveGap(n)` with the number lost, so a UI can note that text is missing. The final answer is still in the `AgentResult`. - If the durable buffer is full - the listener is that many durable events behind - it is disconnected with `StreamEvent.Disconnected(lastSeq, DisconnectReason.Lagging)`. Durable events are in the log, so you can resubscribe from where you were with `GraphRuntime.subscribe(threadId, afterSeq = lastSeq)`. That needs the agent's runtime: build the agent `withRuntime(runtime)` and subscribe on that `runtime`. The subscription is thread-scoped, not run-scoped - it delivers every later run on the thread too - and does not end itself: cancel it when you are done. A listener that throws is disconnected with `ListenerFailed`; the run is unaffected. ## Replay Durable events outlive the run. `GraphRuntime.subscribe(threadId, afterSeq = 0)` replays a thread's events from the start, which gives the structure of every run without any content. See `EventCollectionExample`. ### Tenants A subscription reads the thread, so it is checked like `history`: pass the caller's `RunConfig`, and a thread of another tenant is refused with `GraphError.TenantMismatch` before anything is delivered. `TracingSubscriber.attach` takes the same `config`. The default, `RunConfig()`, is the caller with no tenant, which is right only for threads whose runs set none. (not compile-checked) ```scala runtime.subscribe(threadId, config = RunConfig().withTenantId(TenantId("acme")))(listener) ``` A thread can change hands under its id after you subscribe: one that does not exist yet is created by whichever tenant runs on it first, and one deleted through another runtime can be created again before your subscription learns of the delete. So a subscription screens each event against your tenant, and ends with `Disconnected(lastSeq, TenantMismatch)` - before the first event of another tenant's thread, delivering none of it. `RunHandle.subscribe` and `AgentRun.subscribe` belong to a run, and use its tenant. ## Subscriptions across runtimes Several `GraphRuntime`s, in one process or several, can share one checkpoint store ([Durable Checkpointers](durable-checkpointers)). A `GraphRuntime.subscribe` on any of them delivers the durable events of every commit on the thread, whichever runtime made it, with the same durable guarantees: in `seq` order, without gap or duplicate, and only after the commit. How those events interleave with live ones is the exception: see [Ordering across runtimes](#ordering-across-runtimes). - The subscription's own runtime hands it that runtime's commits as they land. Other runtimes' commits it reads from the store, on a second virtual thread (`llm4s-watch-`) that runs while the subscription is live. A store that is told of commits delivers them at once: `InMemoryCheckpointer`, shared between runtimes in one process. A store that is not is read every `pollInterval`, 250 ms by default; the SQLite store is read this way. - Only durable events cross runtimes. Live progress is never stored, so another runtime's `TextDelta`s and other live events never arrive. - Another runtime's events wait in the store until the listener has room for them, so they never make a subscriber lag. The subscription's own runtime's commits are still delivered in their place. A commit that finds other runtimes' events not yet read reads them first. If they do not fit, the subscriber lags as usual, and you resubscribe from `lastSeq`. - If a read of the store fails, the subscription delivers what it has queued and then ends with `Disconnected(lastSeq, ReplayFailed(error))`. Closing the store under it ends it this way too. Resubscribe with `afterSeq = lastSeq`, for example after a restart. - If the thread is deleted (`GraphRuntime.deleteThread`, `Agent.forget`), through this runtime or another, the subscription delivers what it has queued and then ends with `Disconnected(lastSeq, ThreadDeleted)`: at once in the deleting runtime, and in another runtime at its next read of the store (its next poll, or, with the watch disabled, its runtime's next commit on the thread). It never delivers a new thread created under the same id as a continuation of the deleted one; subscribe again from `afterSeq = 0` to follow that one. A subscription in another runtime that has delivered no event yet cannot be told from a new one, and follows the new thread instead of ending - if it is your tenant's; one of another tenant ends the subscription with `TenantMismatch` (see [Tenants](#tenants)). A subscription that falls behind while its thread is deleted ends with `ThreadDeleted`, not `Lagging`: resubscribing from `lastSeq` would find the log gone. (not compile-checked) ```scala import org.llm4s.agent.graph.* import scala.concurrent.duration.* // a process that only watches threads whose runs execute elsewhere, over the same SQLite file val watcher = GraphRuntime(store, watch = WatchPolicy(pollInterval = 100.millis)) watcher.subscribe(threadId) { case StreamEvent.Durable(record) => println(s"${record.seq} ${record.event}") case _ => () } ``` `WatchPolicy.disabled` turns the watch off, for a runtime that knows it is the store's only user. Its subscriptions then see other runtimes' commits only by subscribing again, which replays the log. `Agent.stream*` and `AgentRun.subscribe`, and the fs2, ZIO, Java and Kotlin streams built on them, follow a run that their own runtime started, so they work as before. To follow a thread whose runs execute in another process, subscribe with `GraphRuntime.subscribe` on a runtime over the same store. ### Ordering across runtimes Concurrently active subscriptions to a thread that stay caught up see one interleaving of durable and live events within one runtime: every event handed to several of them has the same relative placement for each. That guarantee does not cover a subscription that attaches later, which misses earlier live events, or a slower one, which can lose live events (`LiveGap`) or be disconnected as `Lagging`. Durable `seq` order is preserved for every subscription. The guarantee holds when nothing outside this runtime commits to the thread. A commit made by another runtime or process reaches each subscription through its own store watch, through a hand-over's gap read, or, for a subscription still in its initial replay, directly from that replay, so two subscriptions can place it differently; an external commit allows that, but does not necessarily make them differ. If you need one order across runtimes, order only `StreamEvent.Durable(record)` by `record.seq` (or collect those events and sort by `seq`). There is no durable-only subscription option, so filter in the listener. Ignore `StreamEvent.Live` and `StreamEvent.LiveGap`: live events have no `seq` and are not stored, so they cannot be placed in a cross-runtime order. Treat `StreamEvent.Disconnected` as the end of the subscription, not as a record: it carries `lastSeq` and a reason, not an `EventRecord`. The structural fix, one ordered per-thread log shared by all subscriptions, is recorded as future work in the design record ([#1920](https://github.com/llm4s/llm4s/issues/1920)). ## fs2, ZIO and Pekko Streams `llm4s-effect`, `llm4s-zio` and `llm4s-pekko` expose the same stream as a value: (not compile-checked) ```scala // cats-effect / fs2: Stream[IO, AgentStreamItem] agentIO.stream(threadId, "Explain monads").evalMap { case AgentStreamItem.Event(AgentEvents.TextDelta(d)) => IO.print(d.text) case AgentStreamItem.Event(_) => IO.unit case AgentStreamItem.Done(result) => IO.println(s"\n${result.status}") }.compile.drain // ZIO: ZStream[Any, LLMError, AgentStreamItem] agentZ.stream(threadId, "Explain monads").runForeach { /* same cases */ } // Apache Pekko Streams: Source[AgentStreamItem, NotUsed] agentPekko.stream(threadId, "Explain monads").runForeach { /* same cases */ } ``` `AgentStreamItem` is `Event(StreamEvent)` or `Done(AgentResult)`, one enum per module. The stream ends after `Done`; a `Left` from admission or from the run fails the stream with that error. A run that ends without a terminal event (a crash) still ends the stream, as soon as the run's last event has been delivered, and the stream then fails with the run's error. `streamResume` and `streamRecover` exist on all three. A Pekko stream fails with an `LLMException` carrying the error; see [Apache Pekko Integration](../pekko). Interrupting the stream or stopping early (`take(n)`, `head`) cancels the turn. A slow consumer does not: the stream's buffer never holds up the run's subscription. Durable events are kept; live events beyond the buffer's 256 are dropped, and the consumer receives one `StreamEvent.LiveGap(n)` with their count where they were dropped, then the rest of the run and `Done`. A `Disconnected` from the runtime fails the stream, and so does a consumer that has stopped draining altogether: once more than `Agent.StreamCapacity` (1024) durable events wait for it, as the runtime would disconnect a listener that far behind. Samples: `AgentStreamIOExample`, `AgentStreamZIOExample`. ### Typed streams of one run `events(run, eventType)` on `AgentIO`, `AgentZ` and `AgentPekko` is a stream of one event type's decoded payloads, for a run you already hold (from `agent.start`); `events(run, handlers)` turns several types into one ADT of yours: (not compile-checked) ```scala enum Progress: case Text(delta: String) case Checked(path: String) case Gap(lost: Int) val handlers = EventHandlers .on(AgentEvents.TextDelta)(d => Progress.Text(d.text)) .on(Checked)(c => Progress.Checked(c.path)) .otherwise { case StreamEvent.LiveGap(n) => Progress.Gap(n); case _ => Progress.Gap(0) } agentIO.events(run, Checked) // Stream[IO, Checked] agentIO.events(run, handlers) // Stream[IO, Progress] agentZ.events(run, handlers) // ZStream[Any, LLMError, Progress] agentPekko.events(run, handlers) // Source[Progress, Future[Done]] ``` Merge typed streams with the library's own combinators. Such a stream observes the run, through `AgentRun.subscribe`: it replays the run's durable events from its start, then follows it live, and misses live events sent before it started. It ends with the run; it does not raise the run's own error (await the run for that), and stopping it early cancels its subscription, never the run. Handlers run on the stream, so a slow or throwing handler slows or fails the stream only. ## Java and Kotlin The Java facade (`llm4s-java-api`) and the Kotlin API (`modules/kotlin-api`) stream a turn the same way, with the same buffer and the same rules: a slow consumer loses live events and gets one `StreamEvent.LiveGap(n)`, never cancels the run; a run that ends without a terminal event still ends the stream with the run's error; and cancelling the stream cancels the run and returns once it has ended, so the thread can be recovered at once. Both take the thread id as a `String`, as fs2 and ZIO take a `ThreadId`; `streamResume` and `streamRecover` exist on both. To read what a suspended turn waits for (`result.status().pending()`, or `JAgent.pending` and `AgentKt.pending`), or to resume or recover without a stream, see [Suspended turns from Java and Kotlin](index#suspended-turns-from-java-and-kotlin). **Java.** `JAgent.stream(threadId, query, listener)` returns an `LlmResult` at once - a failed result for a refused start, and the listener hears nothing. An `AgentStreamListener` gets each event through `onEvent`, then exactly one of `onComplete(JAgentResult)` or `onError(LlmException)`, all on the stream's own thread. Only `onEvent` is abstract, so a lambda is a listener; `StreamEvents.decode` reads an event as an `Optional`: ```java // streaming = true: the agent's model calls stream, so the turn carries text deltas JAgent agent = Llm4s.createAgent(client, ToolRegistry.empty(), true); LlmResult started = agent.stream(threadId, "Explain monads", event -> { StreamEvents.decode(AgentEvents.TextDelta(), event).ifPresent(d -> System.out.print(d.text())); if (event instanceof StreamEvent.LiveGap) System.out.print("[...]"); }); LlmResult result = started.get().await(); // or started.get().cancel() ``` `AgentStream.await()` returns once the listener has returned from its last call, with the same outcome. `cancel()` cancels the turn and returns once it has ended, or after 5 seconds with a WARN if it has not (see [Threading and cancellation](../java-threading-and-cancellation)); the listener receives at most the event already being delivered, then `onError` with the cancellation - or `onComplete`, when the turn had already ended; an interrupt of the thread calling `cancel()` does not cut that wait short, and is kept. A listener that throws from `onEvent` - or sets its own thread's interrupt flag - cancels the turn, and `onError` receives what it threw, or a `CancelledError`. `Llm4s.createAgent(client)` and `createAgent(client, tools)` build an agent that calls the model's `complete`, so its stream carries no text deltas; pass `streaming = true` for them. `Llm4s.wrapAgent(agent)` takes an agent built with `Agent.builder`, for middleware, a system prompt or a runtime. `streamResume` takes a `List`, built with `Answer.approve(id)`, `reject(id, reason)`, `edit(id, argumentsJson)` or `reply(id, json)`. The Java sample in `modules/samples/gradle-java` streams a turn's text. **Kotlin.** `AgentKt.stream(threadId, query)` is a cold `Flow`: each collection runs the turn, emitting `AgentStreamItem.Event(event)` for each event, then `AgentStreamItem.Done(result)`, a `JAgentResult`. A refused start or a failed run throws `LLMException`. Cancelling the collection - its scope, `take(n)`, `withTimeout` - cancels the turn; starting and cancelling it run on `Dispatchers.IO`. A turn cancelled by anything but the collection fails it with `LLMException`, as in fs2 and ZIO. So does a delivery that dies of a fatal error (an `OutOfMemoryError`, a `LinkageError`): the Java listener then hears no terminal callback, and the flow - or `run`, `continueConversation`, `resume` or `recover` - ends with the facade's report of it instead of suspending. ```kotlin val agent = Llm4s.createAgent(client, ToolRegistry.empty(), streaming = true) agent.stream(threadId, "Explain monads").collect { item -> when (item) { is AgentStreamItem.Event -> StreamEvents.decode(AgentEvents.TextDelta(), item.event).ifPresent { print(it.text()) } is AgentStreamItem.Done -> println("\n${item.result.status().kind()}") } } ``` The flow's channel holds 64 items; past that, the stream's 256-event buffer fills and live events are dropped. ## Your own graphs The same machinery serves graphs that are not agents. An `EventType[A]` names a payload once: (not compile-checked) ```scala val Checked = EventType[Checked]("myapp.checked", 1) // name matches [a-z0-9_.]{1,64} Checked.emit(context, value) // durable: stored with the task's commit Checked.progress(context, value) // live: sent now, never stored listener { case Checked(v) => ... } // None for another name, version or an undecodable payload ``` `RunContext.progress(name, version, payload)` is the untyped form. A tool emits through its context's run: `Checked.progress(context.run, value)`. Progress is sent only while the task's run holds the thread. A node that keeps its `RunContext`, or a thread of its own, past the end of its run sends nothing: once the run has released the thread, or found its claim lost, `progress` is a no-op, so it never reaches a later run's subscribers. ### Typed handlers `EventHandlers` packages typed handlers with their event types, so a selection of mixed types is a value, and each handler gets its own payload type: (not compile-checked) ```scala val handlers = EventHandlers .on(AgentEvents.TextDelta)(d => print(d.text)) .on(Checked)(c => println(s"checked ${c.path}")) .otherwise { case StreamEvent.LiveGap(n) => println(s"[$n live events lost]") case _ => () } agent.stream(threadId, query)(handlers) // also run.subscribe()(handlers), runtime.subscribe(...)(handlers) ``` An event is looked up once by name and version and its payload decoded once. Events no handler takes - kernel events, `LiveGap`, `Disconnected`, other names or versions, a payload that does not decode - go to `otherwise`, or are skipped without it. A second `on` for the same name and version replaces the first. `handlers.dispatch(event)` returns a handler's result, for use in your own code. Live events are at least once across recovery: a tool that `recover` runs again sends its progress again, to the recovered run's stream. Durable events are not repeated in the log - a failed attempt's are discarded with its task - but a handler with an effect of its own should key it by something stable carried in the payload, such as the tool call's `IdempotencyKey`, never by run id. ## Limits - **Kernel failure messages.** `TaskFailed` and `RunFailed` store an error message. If a guardrail's or tool's error quotes content (a guardrail reason that echoes the user's text), that text reaches the log through the kernel event, not through an `agent.*` event. - **Live events are not replayed**, and a late `subscribe` misses earlier ones. A subscription receives every live event sent after `subscribe` returns, so `GraphRuntime.subscribe` followed by `start` sees all of the run's - unless more arrive than its `capacity` holds, while it replays or before its listener drains them: those are dropped and reported as a `LiveGap` with their count. - **Live events stay in their runtime.** A subscription receives another runtime's durable events, but never its live ones, which are not stored. With a polled store, such as SQLite, another runtime's commits arrive up to `WatchPolicy.pollInterval` after they land. See also the [observability guide](../observability/), the [migration note](../../reference/migration.html), and [Typed Agent Runtime §4.14](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.14-stage-1-slice-3-events-and-tracing.md). --- Source: https://llm4s.org/guide/agents/durable-checkpointers # Durable Checkpointers Share one checkpoint store between runtimes and processes, recover a run whose process died, inspect, fork and correct a thread's checkpoint history, prune it, and test a store of your own against the contract every store must meet. --- ## What a checkpointer holds Every agent turn and every graph run executes on a `GraphRuntime`, which stores its threads in a `Checkpointer` (`org.llm4s.agent.graph`). For each thread the store keeps: - its checkpoint history, as versioned JSON: each checkpoint is the graph's state and what was left to run at one superstep boundary, and the latest says what happens next; - the pending writes of tasks that finished in the latest checkpoint's superstep, so `recover` does not run them again; - the durable event log, numbered `1, 2, 3, ...` per thread; - the **claim** of the run that holds the thread, if one does. Two stores ship with LLM4S: - `InMemoryCheckpointer`, the one `GraphRuntime.inMemory()` and `Agent.builder` use. Its threads live as long as its instance. - `SqliteCheckpointer`, in `llm4s-agent-checkpoint-sqlite`: one SQLite file that survives a restart and that processes on one host can share. See [The SQLite store](#the-sqlite-store). Both meet the same contract, described below. ## Runs claim their thread Several `GraphRuntime`s may share one store, in one process or many. A thread may still run only one run at a time, and the store is what enforces it. Before a run does anything, it asks the store for a **claim**: a lease on the thread with an expiry and a **fencing token**. While the run executes, it renews the claim, and it releases the claim when it ends. (not compile-checked) ```scala import org.llm4s.agent.graph.* import scala.concurrent.duration.* val store = InMemoryCheckpointer() // two runtimes over one store - in a real deployment, one per process val a = GraphRuntime(store, claims = ClaimPolicy(ttl = 30.seconds, renewEvery = 10.seconds)) val b = GraphRuntime(store) ``` `ClaimPolicy` sets how long a claim lasts (`ttl`, 30 seconds by default) and how often a running run renews it (`renewEvery`, 10 seconds by default, counted from the moment the claim is granted; it must be shorter than `ttl`). The store judges expiry by its own clock, and the run judges its own lease too (see [Fencing](#fencing-a-run-that-lost-its-claim-stops-and-records-nothing) below). When the store has a single clock - a database server's, or one process's - the runtimes need not agree on the time. An embedded store that each process opens itself, such as SQLite on a shared file, judges expiry by the clock of whichever process grants or renews the claim, so clock skew between hosts shortens or lengthens the effective `ttl`. Fencing (below) still keeps every write safe; skew costs liveness - a live run taken over early, a dead one held longer - and can repeat side effects. Keep hosts' clocks synchronised. `ClaimPolicy` can also carry a `label` naming the runtime to an operator - a host, pod or process name: `ClaimPolicy.default.withLabel("worker-7")`. The runtime then logs the run id with it, at INFO, whenever it is granted a claim (`Run 'r1' (runtime 'worker-7') claimed thread 't' with token 1`), and in the WARN lines for a failed renewal or a lost claim, so the run a `ThreadBusy` names can be traced to the runtime holding it. Without a label the grant is logged at DEBUG. The label goes nowhere else: not to the store, and never into an error another caller - another tenant's included - gets. While one run holds the thread, every other `start`, `recover`, `resume`, `deleteThread` or `updateState` on it, and every `fork` onto it as the target, from any runtime, is refused with `GraphError.ThreadBusy`, which names the holding run: (not compile-checked) ```scala b.recover(threadId, graph) match case Left(GraphError.ThreadBusy(_, latestCheckpoint, GraphError.BusyHolder.Run(holder))) => // run `holder` is live: retry once it has suspended or completed case other => () ``` A refusal up front accepts and discards nothing, so `ThreadBusy` is a `RecoverableError`: the caller retries, and generic retry code (`RetryPolicy`, `ErrorRecovery`) does too. `holder` is a `GraphError.BusyHolder`: - `Run(runId)`, the holding run, when it is of the caller's tenant: a run of the same runtime with the caller's tenant, or a claim the store recorded for the caller's tenant - each claim records its run's `RunConfig.tenantId` - and the thread's latest checkpoint, if it has one, is the caller's tenant's too; - `Unnamed`, when another run holds the thread but cannot be named: its claim is another tenant's, or recorded no tenant (a claim a SQLite file held when it was upgraded to schema 5), or a change's write was refused as stale naming only the claim that replaced it; - `Lapsed`, when it is the call's own claim that was lost - a `fork`, `updateState` or `deleteThread` whose lease passed, or whose store named no current claim, before its write was known to land - and no other run is known to hold the thread. A write not yet sent was never applied, but one already in the store when the loss was found may still land, fenced by the token: read the thread before retrying. A retry can still be refused, by the call's own claim until its release lands or by a run that took the thread over meanwhile. ## Recovering a run whose process died A process that dies stops renewing its claims. Once a claim has expired, another runtime can take the thread over: `recover` there continues the run from its last checkpoint, reusing the pending writes of the tasks that had finished, so they are not run again. (not compile-checked) ```scala // process B, after process A died mid-run: refused until A's claim expires, then admitted b.recover(threadId, graph).flatMap(_.await()) ``` So `recover` tells a live run from a dead one by its claim. It does not wait: until the dead run's claim expires - at most `ttl` after its process stopped renewing - `recover` is refused with `ThreadBusy`, and you retry. `resume` and `start` are refused the same way while another runtime holds the thread. A `recover` whose claim replaced another run's expired, unreleased claim records that run's id on its `RunEvent.RunRecovered` event, as `takenOverFrom`, so the thread's log shows the takeover. That run may have been paused rather than dead, and still executing. It is recorded even when it is the recovering run's own id - a `RunId` reused with `RunConfig.withRunId` - since the expired claim was still another, earlier run's; `takenOverFrom` is `None` when the thread was free. It is `None` too when the claim replaced was another tenant's, or recorded no tenant: the log belongs to the thread's tenant, and never names another tenant's run. A thread's id can be claimed by another tenant's run - one admitted before the thread existed, say - and such a claim can lapse unreleased; the owner's recovery then replaces it without naming it. So a run of another tenant is never named to the thread's tenant: not by `ThreadBusy`, also in the moment between its claim and its first checkpoint, not by `takenOverFrom`, and not by `GraphError.ClaimLost`, whose `current` is only the fencing token of the claim that replaced the run's, a per-thread counter that names no run or tenant. `recover` reads the dead run's pending writes again once its own claim is granted, so a task that finished in the last moments before the takeover is reused, not run again. ## Fencing: a run that lost its claim stops and records nothing A process that was only paused - a long GC, a frozen VM - may wake up after another runtime took its thread over. Every commit a run makes carries its claim's token, and the store refuses any commit whose token is not the thread's current claim, with `GraphError.StaleClaim`. Such a run therefore cannot record anything after its successor started, and the thread's state and event log are its successor's. A run does not wait for a refused commit to learn that its claim is gone. It judges its own lease: the lease ends `ttl` after the moment, by the runtime's monotonic clock (`System.nanoTime`), just before it sent the request that granted or last renewed the claim. The store started its own `ttl` no earlier than that, so the run's lease ends no later than the store's - provided the store judges expiry by one clock that does not run faster than the runtime's monotonic clock and is not stepped forward. A constant offset between the two clocks does not matter. A store clock that runs fast or is stepped forward (an NTP step on `Clock.systemUTC()`), or a store whose processes each judge expiry by their own skewed clock (SQLite shared between processes), can expire the claim while the run still judges it live, so another runtime can take the thread over early. The run then learns of it from its next refused renewal or commit - within `renewEvery` - and stops; fencing keeps every write safe, but tasks it was already running can repeat side effects (see [Limits](#limits)). A heartbeat that never waits on the store checks the lease, so a run whose renewals keep failing, or hang - its store unreachable, or partitioned from it - still notices. A renewal that fails, or a store clock that throws, is retried at the next beat while the lease lasts. The run finds its claim lost when its lease passes with no renewal landing, when a renewal is refused with `StaleClaim`, or when a commit is. It then stops at once, in every durability mode: - its running tasks are interrupted, as `cancel` interrupts them; - it commits nothing more - not even `RunFailed` - and drops what it had not yet written: the queue of `Durability.Async` and the buffer of `Durability.OnExit`. From the moment the loss is found, a commit the run has not yet begun is skipped, even one of its outcome while it is finishing. A commit begins, or is skipped, in one atomic step with that check, so the only commits of the run that can reach the store after the loss are ones that had already begun when it was found. An `Async` commit already in the store is waited for at most `ttl`; one still there then is left to finish on its own. It is fenced by the run's token, so it lands only if nobody has claimed the thread since. If it lands, it lands after the run has ended: its events are an orphan of the run, not part of it. They are in the thread's log, and reach the thread's subscribers in sequence and before any later run's events, but never the run's own listeners or its tracing (see [Limits](#limits)). The same holds for a commit that has landed but that this runtime has not yet handed to its subscribers, because it is still reading, from the store, events committed elsewhere that one of them has not seen yet: a commit reaches every subscription of the thread at once, or none, so all of them, and a replay, agree on it; - it releases its claim, which is a no-op if another run holds the thread. It does not wait for a renewal that may still be in the store, and the release runs on a thread of its own, waited for only until `2 × ttl` after the loss, so the run's result and the thread's release in this runtime never wait on a store that hangs; a release that never lands lets the claim lapse after `ttl`; - it ends with `GraphError.ClaimLost(threadId, token, current)`, where `current` is the thread's current claim when the store named one; - its end is bounded whatever it was doing: `await()` returns, and the thread is free in this runtime, within `2 × ttl` of the loss being found, plus only local work that never calls the store. The loss is found when the lease passes - at most `ttl` after the last renewal that landed was sent - or when a renewal or commit is refused, so the bound is at most `3 × ttl` after the last renewal that landed. If the run has not begun to end by `2 × ttl` after the loss - its thread stuck in a store call that ignores interrupts, such as a `Sync` commit, `OnExit`'s exit commit, or the commit of its outcome, which no stop interrupts - it is ended without its thread: it fails with `ClaimLost`, its thread is freed in this runtime and its claim released as above. When the stuck call does return, it changes nothing: the run has its one result, and its thread is not freed again. A write it makes that lands then is an orphan, as above. The same bound holds while the run is admitted: if the commit that claims the thread for `start`, `recover` or `resume` hangs in the store and the claim is found lost meanwhile, the call returns `ClaimLost` within `2 × ttl` of the loss, with the thread freed and the claim released as above. The run never starts. If that commit lands later, it is fenced by the token: once the release has landed, or another run has taken the thread over, the store refuses it. But one that lands first - the store still holds the claim, since nobody took it over - leaves the admission's `Running` checkpoint and its `RunStarted` (or `RunRecovered` or `RunResumed`) event in the thread although the call returned `ClaimLost`, and no run continues it: `recover` does, once the claim is released. A claim's commit that the store refuses as stale - the claim lapsed, or another runtime took it over, before it landed - is a loss found then: the call returns `ClaimLost`, never `CheckpointWriteFailed`, with the claim released and the thread freed. If the heartbeat declared the loss meanwhile, the call still returns one `ClaimLost`, naming the current claim the first of the two to find the loss named. `fork`, `updateState` and `deleteThread` are bounded the same way while they hold their claim: if it is found lost while the change waits on the store - its next call queued behind a hung renewal in a store that serialises its calls, say - the call returns `ThreadBusy` naming no checkpoint within `2 × ttl` of the loss, with the thread freed in this runtime and the claim released as above. A change is not a run, so it never returns `ClaimLost`: retry it once the thread is free. A change's write - its commit, or the store delete - that has not begun when the loss is found never reaches the store, and the change returns `ThreadBusy` as for a commit the store refuses as stale. A write the store refuses as stale is itself a loss found then, as a run's is: the change returns `ThreadBusy` within `2 × ttl` of it, and gives its claim back as above, without waiting on the store. One already in the store may still land, fenced by the token. A `deleteThread` that lands after its call was given up on ends this runtime's subscriptions to the thread at once only if the call has not yet freed the thread; otherwise they learn of the delete from the store, as another runtime's do (see [Deleting a thread](#deleting-a-thread)). `ClaimLost` is not a store outage (`CheckpointWriteFailed`). After it, `recover` may be attempted once the thread is free: - while a successor holds the thread, `recover` is refused with `ThreadBusy`: retry later; - if a successor took over and finished the work, the thread's state shows it, and `recover` reports `NothingToRecover` (or the thread is suspended or completed, as the successor left it); - if the lease merely lapsed - renewals failed until it passed, and nobody took the thread over - the run released its claim and its last durable checkpoint is still `Running`, so `recover` continues it as after a crash. ### Limits - A stale worker can still act until it notices, and while its tasks see their interrupt. The loss is noticed as the run's own lease passes, or as soon as a renewal or commit is refused - within `renewEvery` of a takeover that a store clock running ahead allowed early. A task already running then may finish an external side effect before it checks its interrupt, and a tool that ignores interrupts runs to its end. Such a side effect can happen in both the stale run and its successor, concurrently. That is the at-least-once boundary every run has: make such tools idempotent. A tool or node that writes to a system able to fence can pass the claim's token on: it is `context.position.fencingToken` in a node and `context.run.position.fencingToken` in a tool, and it grows with every claim, so the system can refuse a token lower than the highest it has seen. - Until the loss is noticed, such a run's live progress events (`StreamEvent.Live`, which are never stored) still reach the subscribers of its own runtime; from then on its tasks send none, even a task that ignores the interrupt and outlives the run. Its durable events are refused or dropped with its commits. A live event carries its run's claim token (`StreamEvent.Live.token`), so a subscription that has been handed a newer claim's commit drops an older claim's progress, and a run's observer and its run-scoped streams never deliver another claim's, even under the same `RunId`. - A `ClaimLost` run's own event stream - `AgentRun.subscribe`, `Agent.stream`, its observer and its tracing - ends at the run's end, which never waits on a hung store. An `Async` write the run abandoned may still land after that; it is then visible only through the thread's log - a thread subscription (`GraphRuntime.subscribe`) or a replay - never as one of the run's events, even to a run-scoped subscription made later that replays the run. That holds for the next run on the thread too, even if it reuses the lost run's `RunId`: a run's own events are those committed under its claim's fencing token (`EventRecord.token`), so a late write that lands while it is being admitted is never delivered to its observer, its stream or its tracing. - A run that has begun committing its outcome finishes that commit: a loss found during it does not interrupt it. The commit is still fenced, so it lands only if nobody has taken the thread over. The run's end does not wait for it past `2 × ttl` of the loss: if the commit returns first the run ends with its outcome (or `ClaimLost`, if the store refused it), and otherwise with `ClaimLost`. - Commits on one thread are written one at a time, in this runtime, so that subscribers see them in order. A commit hung in the store holds up later commits on its thread - including a new run's claim commit there, until the store returns, which the store's own bound on a call limits (see [When the store hangs](#when-the-store-hangs)) - but never another thread's. A subscription's read of the store that hangs holds up only that subscription, and, for a read of events another runtime committed before this runtime's commit, that thread's commits: never another thread's runs, their admission or release, nor a lost run's bounded end. - A process that dies holds its threads for up to `ttl`; so does a store that fails to release a claim - except to its own runtime's `deleteThread`, which tries the release once more first, so `Agent.run(query)` can still forget the thread of a failed turn. A run that ends with its claim still held waits up to `ttl` for a renewal still in the store, so that none lands after its release; if the renewal is still there then, the run releases on a thread of its own without waiting, and its result is set at once. A run whose claim was lost does not wait for its renewal at all. `fork`, `updateState` and a `deleteThread` that did not delete release the claim they hold the same way, so a renewal hung in a store that serialises its calls cannot keep them from returning. ## When the store hangs Every store must return from each call within a bound it documents, and fail a call that reaches it, changing nothing ([#1919](https://github.com/llm4s/llm4s/issues/1919)). `SqliteCheckpointer`'s is twice its `busyTimeout`, plus the time its own SQL takes (see [The SQLite store](#the-sqlite-store)); `InMemoryCheckpointer` never waits on anything but its own brief lock. A store's bound should be well inside `ttl - renewEvery`, the time a renewal has to land, so that a slow call never costs a run its claim. The runtime bounds two waits itself, both by `2 × ttl`, so that a store that breaks the contract cannot hold a run's result or its thread: - **A run that lost its claim** ends within `2 × ttl` of the loss, whatever it was doing, with `ClaimLost` (see [Fencing](#fencing-a-run-that-lost-its-claim-stops-and-records-nothing)). So does an admission, with `ClaimLost`, and a `fork`, `updateState` or `deleteThread`, with `ThreadBusy`. - **A run's closing wait while it holds its claim.** An `Async` run waits for its queued commits before it returns, and an `OnExit` run makes its one exit commit. If a commit stays in the store for `2 × ttl` with none returning, the run ends with `GraphError.CommitTimedOut(threadId, after)`, without waiting for it: `await()` returns, its thread is free in this runtime, and its claim is released on a thread of its own, which nothing waits for - a store stuck on a commit may serialise the release behind it. A drain that keeps making progress is never cut short, however long it takes in all. The commit is an orphan, as a lost run's late write is: it is fenced by the run's claim, so once the release lands the store refuses it, but it can land first. Its events then reach the thread's subscribers, never the run's own listeners or its tracing. So after `CommitTimedOut`, read the thread: its latest checkpoint is either the run's last durable one, still `Running` - `recover` continues the run - or the run's outcome. The bound is twice `ttl` rather than a setting of its own. A store that keeps claims alive already returns well inside `ttl`, since a renewal must land within `ttl - renewEvery` and a store that serialises its calls queues renewals behind its commits; the runtime's bound is a backstop for a store that breaks the contract, and the same `2 × ttl` that bounds a lost run's end. Every other call made while the claim holds - an admission's claim commit, a `Sync` run's commits, its outcome's included, a change's write, and the release of a claim that was not lost - is waited for as long as it takes: only the store's bound limits it. Nor can the runtime free what a call stuck in the store holds itself. While the call lasts: - **its thread stays alive.** A run thread, or the virtual thread on which an admission, `fork`, `updateState` or `deleteThread` makes its store calls, stays inside the call after the runtime has given it up - and an `Async` run's writer thread with it. Only the run's result, its slot and its claim are freed; - **it holds up the thread's later commits.** Commits on one thread are written one at a time in a runtime, so a commit, or a hand-over's read of events another runtime committed, holds up every later commit on that thread in that runtime until it returns - a new run's claim commit included - but never another thread's; - **it can hold up subscriptions.** A `GraphRuntime.subscribe` made while a run's claim commit is in the store, on a thread with nothing of that run handed over yet - an admission given up on after a lost claim included - waits for that commit to return, or to be cancelled, before it delivers its replay; a run-scoped subscription (`RunHandle.subscribe`, `AgentRun.subscribe`) never does. And with the store watch disabled (`WatchPolicy.disabled`), a replay made while a write a run gave up on is still in the store stops at the first event another runtime committed after what this runtime has handed over; it gets the rest when this runtime next commits on the thread, and a replay made once the write has returned has them all. ## Deleting a thread `GraphRuntime.deleteThread(threadId, config)` (`Agent.forget`) claims the thread, checks its tenant under that claim, and then has the store delete it with the claim's token, renewing the claim while the store works, as a run does. The store's delete is fenced like a commit: it deletes only while the token is the thread's current claim, atomically. So a runtime that stalls past its claim's `ttl` between the claim and the delete - a long GC pause, a slow store - while another runtime takes the thread over deletes nothing of that run: the call returns `ThreadBusy` (or `TenantMismatch`, if the run is another tenant's). A deleted thread's id names a new thread, but the new thread does not reuse the old event numbers: it numbers its events on past the deleted thread's, one number skipped. A subscription follows a thread, not its id, so a subscription to the deleted thread ends with `Disconnected(lastSeq, DisconnectReason.ThreadDeleted)` once it has delivered what it had queued, and never delivers the new thread's events as if they continued the old log: - in the deleting runtime, every subscription made before `deleteThread` returns - before the delete, or while it is in flight - ends at once; - in another runtime sharing the store, a subscription ends when it next reads the store: when its watch next polls, or, with `WatchPolicy.disabled`, only when its own runtime next commits on the thread; - in another runtime, a subscription that has delivered no event yet - still at `afterSeq = 0`, as one to a thread that never numbered an event always is - cannot be told from a new subscription, so it follows the new thread from its first event instead of ending - if the new thread is its tenant's; otherwise it ends with `TenantMismatch` before that event ([Tenants](streaming#tenants)). Subscribe again from `afterSeq = 0` to follow the new thread from its first event. There is no unfenced delete: to delete a thread outside a runtime, claim it and delete with the claim's token. ### Limits - A deleted thread that numbered any event leaves a small record in the store, a tombstone: its next event number and when it was deleted, nothing of its data. It is what lets a reader of the deleted log, in any process, be told that the thread was deleted, and a new thread of the id be numbered past it. `prune` never removes it, so a store grows by one per deleted thread - `Agent.run(query)` forgets the thread of every failed or cancelled one-shot turn, and child retention deletes children - until a [sweep](#sweeping-the-whole-store) with a `tombstoneAge` reclaims the old ones. Deleting the same id again, with no event since, adds nothing. ## Subscriptions across runtimes A subscription sees every commit on its thread, made through any runtime or process that shares the store, not only through its own: durable events in `seq` order, without gap or duplicate, as for its own runtime's commits. (Where another runtime's commit falls among this runtime's live events is not fixed: it depends on whether the subscription's initial replay, its store watch or a hand-over's gap read delivers it first; see [Ordering across runtimes](streaming#ordering-across-runtimes).) Each live subscription watches the store with `Checkpointer.awaitEventsAfter`. A store that is told of commits, such as PostgreSQL's (by `LISTEN`/`NOTIFY`, [below](#the-postgresql-store)), returns at once. A store that is not, such as SQLite, is polled every `WatchPolicy.pollInterval` (250 ms by default): (not compile-checked) ```scala val runtime = GraphRuntime(store, watch = WatchPolicy(pollInterval = 100.millis)) ``` Only durable events cross runtimes; live progress is never stored. A thread deleted through any runtime ends its subscriptions with `ThreadDeleted`, with the exceptions [Deleting a thread](#deleting-a-thread) lists. See [Subscriptions across runtimes](streaming#subscriptions-across-runtimes) for the delivery and back-pressure rules. ## The SQLite store (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-agent-checkpoint-sqlite" % llm4sVersion // same version as llm4s-agent ``` `SqliteCheckpointer` keeps every thread - its latest checkpoint, pending writes, event log and claim - in one SQLite database file. Open it with a path, hand it to a `GraphRuntime` (or to `Agent.builder` through `withRuntime`), and close it when the process is done with it: (not compile-checked) ```scala import org.llm4s.agent.graph.* import org.llm4s.agent.graph.sqlite.SqliteCheckpointer import java.nio.file.Path for store <- SqliteCheckpointer.open(Path.of("/var/lib/my-app/runs.db")) runtime = GraphRuntime(store) handle <- runtime.start(threadId, graph, input) result <- handle.await() yield result ``` `open` creates the file if it does not exist (its directory must), and brings it to the current schema version. Any number of stores may open one file at the same moment, also a new one: each waits up to `busyTimeout` for the others. The path may hold any character the file system allows, `?` and spaces included. It must be on a local disk: a Windows UNC path (`\\server\share\runs.db`) becomes a `file://server/share/runs.db` URI, which the native libraries bundled with sqlite-jdbc refuse (they are built without `SQLITE_ALLOW_URI_AUTHORITY`), and WAL does not work on a network file system anyway (see [Sharing one file between processes](#sharing-one-file-between-processes)). An open interrupted while it waits for a busy file returns a `CancelledError` and closes its connection. `SqliteCheckpointer.open(path, clock)` and `open(path, clock, config)` take the clock claims expire by and a `SqliteCheckpointerConfig`. ### Restart and recovery Everything a run commits is in the file, so a process that stops - crashes, is killed, or is redeployed - loses only the work in flight. A new process opens the same file and continues: (not compile-checked) ```scala // after a restart: the same file, a new store, a new runtime, the same graph for store <- SqliteCheckpointer.open(Path.of("/var/lib/my-app/runs.db")) events <- store.eventsAfter(threadId, 0L, 100) // the stopped run's event log, as it was committed handle <- GraphRuntime(store).recover(threadId, graph) result <- handle.await() yield result ``` `recover` reuses the pending writes of the tasks that had completed, so they are not run again; only the tasks that were running or not yet started run. A run that was killed still holds its claim until the claim expires (`ttl`, 30 seconds by default), so `recover` is refused with `ThreadBusy` until then; retry it. A run that failed - a node returned `NodeResult.Fail` - released its claim as it ended, and `recover` is admitted at once. Event numbers continue where the stopped run's log ended: none is reused, and a subscriber that had seen events `1..n` resubscribes with `afterSeq = n`. The sample `org.llm4s.samples.durable.SqliteRestartRecoveryExample` shows the whole sequence: `sbt "samples/runMain org.llm4s.samples.durable.SqliteRestartRecoveryExample"`. ### Sharing one file between processes Several stores - in one process or in several on the same host - can open one file, each with its own connection, and the runtimes over them exclude each other through claims exactly as runtimes over one store do. Every write is one `BEGIN IMMEDIATE` transaction, in which the claim's token and the thread's version are checked and the commit applied, so two stores cannot both pass the checks. - **WAL mode.** The store switches the file to SQLite's write-ahead log. Readers - `latest`, `eventsAfter`, a subscriber's replay - never wait for a writer, and a writer never waits for readers. WAL needs memory shared between the processes that open the file, so keep the file on a local disk, not on a network file system. - **One writer at a time.** SQLite admits one writing transaction per file. The store's writes are short, so a write that finds another in progress waits for it - up to `SqliteCheckpointerConfig.busyTimeout`, 5 seconds by default - and only then fails, with a `ProcessingError` and nothing changed. Raise it if many processes write to one file at once: `SqliteCheckpointer.open(path, clock, SqliteCheckpointerConfig(busyTimeout = 15.seconds))`. - **Writes take turns within a process.** SQLite does not queue a waiting writer. It sleeps and tries again, and gets the lock only if it wakes between two of the other connection's transactions. A store beside one that commits back to back could therefore wait out its whole `busyTimeout`, even though each write was short. The stores in one JVM that open the same file take turns through a fair lock for the file, first come, first served, before each write. A write waits for the writes queued ahead of it, up to `busyTimeout`, and then up to `busyTimeout` again for SQLite's lock if another process holds it. Writes from different processes are still ordered by SQLite's polling, so a process that writes steadily can starve another process's write. Raise `busyTimeout` there. A write interrupted while it waits for its turn returns a `CancelledError`. - **Every call is bounded.** A call waits for the store's own earlier calls, and a write then for its turn at the file, up to `busyTimeout` from the call's start, and a write then up to `busyTimeout` again for SQLite's lock: so every call returns within `2 × busyTimeout`, plus the time its own SQL takes, failing with a `ProcessingError` and nothing changed once it reaches a limit. That SQL itself cannot be timed out: sqlite-jdbc's native calls ignore interrupts, so a disk that stalls inside a write holds that call, and the store's later calls fail once they have waited `busyTimeout` for it. With the default 5 seconds and the default `ttl` of 30 seconds, the bound is well inside `ttl - renewEvery`; keep it so if you raise either (see [When the store hangs](#when-the-store-hangs)). - **Durable commits.** `synchronous = FULL`: a commit that returned survives a crash or a power loss. - **Calls within one store are serialised.** A store is one connection; it is safe to share between threads, and a busy runtime gains from a store of its own rather than from sharing another runtime's. - **Virtual threads.** sqlite-jdbc makes its native calls inside `synchronized` methods, so on JDK 21 a call from a virtual thread - as the runtime's tasks are - pins its carrier thread for as long as the call lasts: up to `busyTimeout` while it waits for another connection's write. Calls within one store are serialised, so each store pins at most one carrier at a time; keep `busyTimeout` short if many stores in one process write to busy files. ### Watching the file SQLite cannot tell one connection that another has committed, so a subscription over a `SqliteCheckpointer` sees the commits of other stores on the file by reading the event log, once per `WatchPolicy.pollInterval`. Each read is a short indexed query, and in WAL mode it never waits for another connection's writer. It can wait for its own store, though: the calls on one `SqliteCheckpointer` share its one connection and are serialised behind its lock, so a watch's read waits while that store is committing - including a commit that is itself waiting, up to `busyTimeout`, for another connection's write. A process's own runtime still delivers its own commits at once. Each live subscription reads once per interval, so where many subscriptions watch one file, raise the interval. A subscription whose store is closed under it ends with `Disconnected(lastSeq, ReplayFailed(...))`, from which the next process resubscribes. ### Clocks A claim's expiry is judged by the clock of the store that grants or renews it - `Clock.systemUTC()` unless you pass one. Stores on one host share that host's clock. Processes on different hosts cannot share one SQLite file safely anyway (see WAL above), but if host clocks still differ - containers with skewed clocks, a clock step - a claim lasts longer or shorter than `ttl` as other processes see it. Fencing keeps every write safe regardless. A store clock running ahead lets another process take over a live run early; that run then finds its commits or renewals refused and stops, but tasks it was already running can repeat side effects (see [Limits](#limits)). A store clock running behind holds a dead run's thread longer. A run's own lease, judged by its monotonic clock, does not protect against a store clock that runs ahead: the run learns of the takeover only from its next refused renewal or commit. ### Schema versions The file records its schema version: 2 since the fenced delete (which added `first_seq`, where a deleted thread's id continues its event numbering), 3 and 4 for effect records, and 5 since claims record their tenant (`claim_tenant`, with `claim_tenant_token`, the token of the claim the tenant belongs to), 6 for child threads, and 7 since each checkpoint and event records when the store committed it, which retention judges age by (rows from before 7 are judged by their producer's timestamps), and 8 for the thread registry (see [Finding threads](#finding-threads)), filled from each thread's latest checkpoint when a version-7 file is opened, and 9 for tombstone reclamation (see [Sweeping the whole store](#sweeping-the-whole-store)), which dates the tombstones a version-8 file holds from the upgrade. A claim a version-4 file holds when it is upgraded has no tenant recorded, so it matches no tenant: its holder is named to nobody, not even its own tenant, and a recovery that takes it over records no `takenOverFrom`. Such a claim is no older than its `ttl`, so this lasts at most that long. Stop every process running an older build before opening its file with this one: an older process that already has the file open is not refused, and until it stops its deletes are not fenced and drop the record a deleted thread's id numbers on from. Opening a file at an older version upgrades it in one transaction, and opening a file written by a newer build is refused with a `ProcessingError`, so an older process never misreads it. Checkpoints inside keep their own format version, as with every store, and are migrated when read. ### What it stores Only data: checkpoints, pending writes and events as JSON, never closures or class names, so a file can be read by any build that knows the graph. Its tables are prefixed `llm4s_checkpoint_`, so the file can hold other tables too. It keeps every checkpoint of a thread's history, as the next section describes: `history` pages through it newest first by an index, so a long history costs a page, not a scan, and `prune` and `deleteThread` remove from it in one transaction each, safely beside other stores writing the file. `prune` reads the thread's whole history and event log inside its transaction, so on a very long log it holds the file for a while and other stores' writes wait, up to `busyTimeout`; prune regularly, or off-peak, rather than once after months. ## The PostgreSQL store `PostgresCheckpointer`, in `llm4s-agent-checkpoint-postgres` (`org.llm4s.agent.graph.postgres`), keeps threads in a PostgreSQL schema that runtimes on any number of machines share: the store for one agent service on several machines. [Running agents on several machines](multiple-machines) puts it together with the run queue, workers, cancel, tracing, metrics and retention into one service. (not compile-checked) ```scala import org.llm4s.agent.graph.GraphRuntime import org.llm4s.agent.graph.postgres.{ PostgresCheckpointer, PostgresCheckpointerConfig } for store <- PostgresCheckpointer.open( "jdbc:postgresql://db:5432/app", user, password, PostgresCheckpointerConfig.default.withSchema("agents") ) runtime = GraphRuntime(store) // ... yield store.close() ``` `open` builds a HikariCP pool of its own; `PostgresCheckpointer.create(dataSource, ownsDataSource, config)` uses a `DataSource` the application already has, and closes it with the store only if `ownsDataSource`. - **One schema, created and migrated on open.** The tables live in the configured schema (`llm4s` by default), so the store can share a database. Opening creates the schema if it is missing and brings it to the current version in one transaction under an advisory lock, so instances starting together migrate it once; a schema already current is only read, so the application's role needs no `CREATE` once it exists. - **The database decides time.** Claim expiry and retention age are judged by the server's clock, never a host's, so skew between machines changes nothing. - **Every write locks the thread's row** (`SELECT ... FOR UPDATE`) and checks the claim, the fence and the parent checkpoint before it writes; two rows are always locked in the order of their ids, so fenced commits never deadlock. Fencing tokens come from one sequence for the schema. - **Every call returns within `connectionTimeout + callTimeout`** (5 + 5 seconds by default): the call timeout is set on the server for each transaction and enforced on the client, which discards a connection whose call overran it. A caller-supplied pool's own connection timeout stands in for `connectionTimeout`; set a driver socket timeout on it. - **Subscriptions are told of commits** by `LISTEN`/`NOTIFY`. A commit that stores events, and a delete, notifies the schema's channel (`llm4s_checkpoint_`) inside its transaction, so only a commit that lands is announced; the payload is a hash of the thread id, nothing of its content. Each store opens one more connection, outside its pool, on its first watch (`ApplicationName` `llm4s-agent-checkpoint-postgres-listener`), and keeps it until `close`: count it when sizing `max_connections`. Over a `DataSource` that is not a HikariCP pool with a JDBC URL, that connection is taken from the `DataSource` instead, and held. A commit through any store over the schema wakes a waiting subscription within a second on a healthy database, whatever its `pollInterval`. - **A lost listening connection degrades to polling.** While it is down - before it first opens, after the server or network drops it, or once its heartbeat (a `SELECT 1` whenever `callTimeout` passes quietly) fails - each watch reads once per `WatchPolicy.pollInterval`, as with SQLite, and the store reconnects in the background. A notification is only a wake-up: the watch always reads the log, so a missed or repeated one loses nothing. Each watch still reads once per `pollInterval` while it waits, so where many subscriptions watch one database, a longer `pollInterval` cuts reads without slowing delivery. ### Upgrades A release that changes neither the store's schema nor the checkpoint format can replace instances one at a time. A release that changes either needs every instance stopped before the first new one starts: an old instance that meets the newer schema refuses to open with `GraphError.UnsupportedStoreSchema`, and writes nothing. [Running agents on several machines](multiple-machines#upgrades) covers the rest of a rolling upgrade: draining, and runs in flight at an older graph version. ## Choosing the store in application.conf `CheckpointerConfigLoader.open()` (`org.llm4s.config`, in `llm4s-agent`) opens the store `llm4s.agent.checkpointer.kind` names, and `RuntimeConfigLoader.runtime(checkpointer)` builds a `GraphRuntime` over it with the `ClaimPolicy` and `WatchPolicy` from `llm4s.agent.runtime`. Changing store is then a dependency and a setting, with no code: ```hocon llm4s.agent { checkpointer { kind = "postgres" # memory (the default) | sqlite | postgres; or LLM4S_CHECKPOINTER postgres { url = "jdbc:postgresql://db:5432/app" # or LLM4S_CHECKPOINTER_POSTGRES_URL user = "agents" # or LLM4S_CHECKPOINTER_POSTGRES_USER # password: LLM4S_CHECKPOINTER_POSTGRES_PASSWORD schema = "agents" callTimeout = 5 seconds connectionTimeout = 5 seconds maxPoolSize = 10 } sqlite { path = "/var/lib/app/threads.db" # or LLM4S_CHECKPOINTER_SQLITE_PATH busyTimeout = 5 seconds } } runtime { claims { ttl = 30 seconds, renewEvery = 10 seconds } # label: LLM4S_RUNTIME_LABEL watch { enabled = true, pollInterval = 250 milliseconds } } } ``` (not compile-checked) ```scala import org.llm4s.agent.service.RunSubmitter import org.llm4s.config.{ CheckpointerConfigLoader, RunQueueConfigLoader, RuntimeConfigLoader } for store <- CheckpointerConfigLoader.open() // a CheckpointStore: close it when the application stops runtime <- RuntimeConfigLoader.runtime(store.checkpointer) agent <- Agent.builder("assistant", client).withRuntime(runtime).build() queue <- store.runQueue // the store's own run queue, on the same connection or pool settings <- RunQueueConfigLoader.load() // llm4s.agent.queue submitter = new RunSubmitter(queue, store.checkpointer, settings) yield (store, agent, submitter) ``` - **`memory`** is built into `llm4s-agent` and is the default; **`sqlite`** needs `llm4s-agent-checkpoint-sqlite` and **`postgres`** `llm4s-agent-checkpoint-postgres`. Each store module ships its block's defaults in its own `reference.conf`. A kind whose module is not on the classpath fails only `open`, with an error naming the module. - **The store's run queue comes with it.** `store.runQueue` (a `Result[RunQueue]`) is the queue in the same database as the checkpoints, on the checkpointer's own connection or pool, so `llm4s.agent.queue` holds only the queue's own settings, as above. All three built-in kinds have one; a store whose backend gave it none answers a `ConfigurationError`. - **A busy thread is the submission's choice** (`policy` on `submitter.submit`): `BusyThreadPolicy.Reject`, the default, refuses it with `ThreadBusy` while the thread has an open entry or a run holds it; `Enqueue` waits behind them; `Interrupt` stops the thread's running queued run at its next superstep boundary - its model call cut, tool calls already started finished and kept, the rest answered "interrupted" - and runs next, ahead of the queue. - **Cancel from any machine** with `submitter.cancel(tenant, CancelTarget.Entry(ticket.entryId))` (or `CancelTarget.Thread(threadId)`): an entry not yet started closes at once; a running one is stopped by its worker at the worker's next lease renewal, its thread settled so the next entry starts normally, and its ticket closes `Cancelled` - or with the run's own outcome if it finished first. `submitter.deleteThread(runtime, tenant, threadId, timeout)` cancels the thread's entries, waits for them to close, then deletes it. - **Only the selected kind's block is read**, so a half-written `postgres` block does not matter while `kind = sqlite`. - **Credentials stay out of logs.** No setting's value reaches a log line or an error; set the password through its variable rather than in a checked-in file. - **A store of your own** registers a `CheckpointerBackend` (`org.llm4s.agent.graph.spi`): a `class` with a public no-arg constructor, listed in `META-INF/services/org.llm4s.agent.graph.spi.CheckpointerBackend`, that opens its store from its block `llm4s.agent.checkpointer.`, handed to it as a `CheckpointerSettings`. `CheckpointerBackends.of(...)` registers backends explicitly, for a shaded jar whose services files were lost. ## Checkpoint history, fork and updateState A store keeps every checkpoint a thread's runs commit, not only the latest. `history` lists them newest first, a page at a time, and `checkpoint` reads one by id. Each is data; restore one with the graph to read its typed state: (not compile-checked) ```scala val latestPage = runtime.history(threadId) // up to 100, newest first val nextPage = runtime.history(threadId, before = Some(latestPage.toOption.get.last.id)) val state = runtime.checkpoint(threadId, id).flatMap(c => graph.restore(c.snapshot)).map(_.state) ``` Checkpoint ids are `/`, where `n` counts along the thread, so an id never repeats on a thread even when one `RunId` serves several runs. **`fork`** starts a new thread from any checkpoint of the history. The new thread's first checkpoint carries that checkpoint's state, scheduled work, parked interrupts and status, and its event log begins with a `RunEvent.ThreadForked`. Continue it as its status says: `start` after `Completed` or `Failed`, `resume` after `Suspended`, `recover` after `Running`. The source thread is only read and is left unchanged. (not compile-checked) ```scala runtime.fork(threadId, checkpointId, ThreadId("what-if")).flatMap { _ => runtime.start(ThreadId("what-if"), graph, "a different question") } ``` A fork never copies pending writes, so `recover` on a fork of a `Running` checkpoint runs that checkpoint's tasks again, in the new thread. The target must be a new thread id (`ThreadExists` otherwise). **`updateState`** applies a typed `StateUpdate` to the thread's latest checkpoint and commits the result as a new checkpoint, recorded with a `RunEvent.StateUpdated`. Use it to correct a thread's state, for example before `resume`. It is optimistic: you name the checkpoint you read, and it is refused with `CheckpointConflict` if the thread has moved on since. Only the state changes - the status, scheduled tasks and parked interrupts stay - so the same `start`, `recover` or `resume` continues the thread afterwards. (not compile-checked) ```scala for latest <- runtime.history(threadId, limit = 1).flatMap(_.headOption.toRight(ValidationError("thread", "no checkpoint yet"))) updated <- runtime.updateState(threadId, graph, latest.id, StateUpdate.update(notes, "corrected"), asNode = Some(writer)) handle <- runtime.resume(threadId, graph, answers) yield handle ``` With `asNode`, the update must stay within that node's declared write set, as if the node had returned it. The thread is held, with a claim in the store, while it is updated, so a live run refuses it with `ThreadBusy`. If that claim lapses, or another runtime takes it over, before the update's commit lands, the store refuses the commit and `updateState` returns `ThreadBusy` too, with nothing applied: retry it. So does a claim found lost before the commit begins: the commit is never sent. And if the claim is found lost while the update waits on a store that hangs, `updateState` returns `ThreadBusy` within `2 × ttl` of the loss rather than waiting (see [Fencing](#fencing-a-run-that-lost-its-claim-stops-and-records-nothing)). `fork` does the same for its target. Neither is a run, so neither returns `ClaimLost`. Pending writes of tasks that already completed are carried over, so `recover` still does not run them again. They apply after your update when the thread is recovered, so a carried write to a key you edited can overwrite your edit on `recover`. To be sure an edit stands, update a `Running` thread only in keys its completed tasks did not write. Tool idempotency keys are derived from the thread, the checkpoint id, the superstep, the task and the call id. `updateState` moves the thread on by one superstep, and checkpoint ids count along the thread, so no later call reproduces an earlier key, whatever `RunId` you reuse. A fork copies the checkpoint's snapshot as it is. Once [#1739](https://github.com/llm4s/llm4s/issues/1739) lands, tool calls already issued at the forked checkpoint record their idempotency keys in the snapshot (in their `ToolTask`, `ApprovalRequest` or `ToolQuestionRequest`), so in the new thread they keep the source thread's keys; only model calls the fork makes afterwards derive new keys, since the key includes the thread. This is intended: a fork that re-runs a call the source already issued - `recover` of a `Running` checkpoint, or `resume` of a parked approval or question - is de-duplicated against the source's call by its key instead of performing the side effect a second time. > `forget` (`deleteThread`) starts a thread id afresh. Reusing both the thread id and a `RunId` after it repeats > the earlier checkpoint ids and supersteps, and so the earlier tool idempotency keys. Use a fresh `RunId` (the > default) for runs on a forgotten thread id. ### A guardrail Block leaves no trace in the history When a node blocks a run (`NodeResult.Block`, as a guardrail does), the run's closing `Failed` checkpoint **retracts its turn**: every checkpoint after the thread's last `Completed` or `Failed` one is removed from the history in the same commit - including the checkpoints of earlier runs of the same turn, such as one that suspended for review. Nothing of the blocked turn stays readable through those checkpoints with `history`, `checkpoint` or `fork`. Events carry no message content and stay in the log. The retraction removes the turn's *earlier* checkpoints. The closing `Failed` checkpoint itself - the thread's latest, which `history`, `checkpoint`, `fork` and the next `start` read - holds whatever the blocking node's own `Block` update leaves in the state. `Agent`'s output guardrail commits `MessageUpdate.RemoveTurn` there, and an input Block blocks before the loop stores the turn, so with `Agent` nothing of the turn remains. **A custom blocking node must remove the content in its `Block` update** the same way, or the closing checkpoint keeps it. One window remains before the guard runs. The model step commits the answer, and the output guardrail runs in the next superstep, so between those two commits `history`, `checkpoint` and `fork` can read the unguarded answer. The Block retracts it from this thread afterwards, but a fork made in that window is another thread and keeps its copy; the fork's copy of the open turn is guarded when the fork continues that turn. ## Retention The history grows with every superstep. `prune` removes what a `RetentionPolicy` does not keep, by age or by size, alongside `compactEvents`: (not compile-checked) ```scala runtime.prune(threadId, RetentionPolicy(maxAge = Some(30.days), maxCheckpoints = Some(50), maxEvents = Some(10000))) ``` - `maxAge` removes checkpoints and events older than it, by the store's clock; - `maxCheckpoints` keeps the newest checkpoints, and `maxEvents` the newest events. The latest checkpoint and its pending writes are always kept, so pruning never changes what the thread does next. Pruning a checkpoint also removes the events recorded before the oldest checkpoint kept; `maxAge` and `maxEvents` may remove more events than that, including those of checkpoints still kept, the latest checkpoint's among them. Removing events raises the replay floor (`ReplayUnavailable`) as `compactEvents` does. `prune` needs no claim, so it can run while a run executes; call it on a schedule that suits you. `maxAge` compares the store's clock with the time the store committed each checkpoint and event, which it records by that same clock - never with the `createdAt` and event timestamps the committing runtime stamped by its own, which stay as the producer's record. So a host whose clock is skewed makes nothing too old or too young. The SQLite and in-memory stores judge by the clock they were opened with; the PostgreSQL store by the database server's. ### Sweeping the whole store `prune` works on one thread. `Checkpointer.sweep` applies a policy to every thread the store holds, a bounded page at a time, and reclaims the tombstones of threads deleted long enough ago (design 4.53): (not compile-checked) ```scala val policy = SweepPolicy( retention = RetentionPolicy(maxAge = Some(30.days), maxCheckpoints = Some(50)), tombstoneAge = Some(7.days), batchSize = 100 ) @annotation.tailrec def sweepAll(after: Option[ThreadId]): Result[Unit] = checkpointer.sweep(policy, after, SweepGuard.none) match case Right(page) if page.next.isDefined => sweepAll(page.next) case other => other.map(_ => ()) ``` - Each call visits at most `batchSize` threads (1 to 1000), tombstones included, and returns the cursor of the next page, so every call stays within the store's bound however many threads it holds. The PostgreSQL store makes a call in up to three short transactions, so one returns within three times its bound. - Each thread is pruned exactly as `prune(threadId, policy.retention)` would prune it, by the store's commit times. - A tombstone is reclaimed once the store deleted the thread more than `tombstoneAge` ago, by its clock, and nothing was stored under the id since (a claim counts until it has lapsed for `tombstoneAge` too). - A reader of a reclaimed log is still refused with `ThreadDeleted`: reclaiming raises the store's reclamation mark, and a new thread of any id without a tombstone numbers its events from it, past every reclaimed thread's. So once a store has reclaimed a tombstone, new threads no longer number from `1`. Paging `children` after a reclaimed child refuses that cursor. - A `SweepGuard` names the threads something outside the store still depends on: each keeps its tombstone, and the newest checkpoint each of its named `RunId`s committed, whatever the policy. A run queue's open entries are what a guard will name (Stage 5, rule 4); `SweepGuard.none` protects nothing. A sweep needs no claim. Run one at a time per store, on a schedule: two at once are safe, only wasteful. ## Finding threads Every store keeps a thread registry (design 4.52): a tenant's root threads can be listed and searched, and the threads a run left unfinished found, without knowing their ids. A thread's **metadata** is a small map of strings for finding it again - not for content - set by the `start` that brings it and replaced with `updateMetadata`: (not compile-checked) ```scala val config = RunConfig().withTenantId(TenantId("acme")).withMetadata(Map("customer" -> "c-42", "channel" -> "web")) runtime.start(threadId, graph, input, config) runtime.updateMetadata(threadId, ThreadMetadata("customer" -> "c-42", "channel" -> "email"), config) val page = store.threads( ThreadQuery(Some(TenantId("acme"))) .withStatus(ThreadStatus.Suspended) .withMetadata(ThreadMetadata("customer" -> "c-42")) ) page.threads.foreach(t => println(s"${t.threadId.value} ${t.status} ${t.updatedAt}")) val next = page.next.map(cursor => store.threads(ThreadQuery(Some(TenantId("acme"))).withAfter(cursor))) ``` - **Tenant-scoped.** Every query names one tenant (`None` finds only threads of no tenant) and never sees another's. - **Newest first** by `updatedAt`, when the store last committed a checkpoint or metadata to the thread, by its clock; paged by a cursor, at most `ThreadQuery.MaxLimit` (500) a page. A thread changed while you page moves to the front: a later page of the same pass does not list it again, and the next pass (or `updatedSince`) finds it. - **Filters**: status, agent (the graph id; an `Agent`'s is its id), metadata entries (all must match) and `updatedSince`. - **Root threads only.** A child thread is read through `children`; a deleted thread is not listed. - **Status** is judged when the store is read: `Running` while a live claim holds the thread, wherever its run is; otherwise `Idle` (the latest checkpoint is incomplete: a run stopped - crashed, cancelled or drained - and `recover` would continue it), `Suspended`, `Completed` or `Failed`; `Deleted` while a cascade delete is under way. `store.incompleteThreads(tenantId, after, limit)` lists the `Idle` threads oldest first, for an operator to look at. It is not how a service recovers work: a cancelled run, a drained one and a crashed one all look alike here, and only a run queue's entry says which should run again. Metadata is limited to 32 entries, keys of `A-Z a-z 0-9 _ . : / -` up to 64 characters and values up to 256 (`ThreadMetadata.of` returns a `ValidationError` past them). The SQLite and PostgreSQL stores index every query, so a page costs the same however many threads the store holds. ## Writing a store A store implements `Checkpointer`: `claim`, `renew` and `release` for claims; `commit`, `latest`, `history`, `checkpoint`, `eventsAfter`, `compactEvents`, `prune` and `deleteThread` for the data. `awaitEventsAfter`, which subscriptions use to watch the store, has a default that polls `eventsAfter`. The Scaladoc of `Checkpointer` states the contract. In short: - grant at most one live claim per thread, by the store's clock, and replace an expired one; - record each claim's tenant (`ClaimRequest.tenantId`, returned as `RunClaim.tenantId`), and name a claim's holder to a later claim - as `BusyHolder.Run` in its `ThreadBusy`, or as its `RunClaim.takenOverFrom` - only when that claim is of the same tenant (`None` and `Some` differ); otherwise refuse with `BusyHolder.Unnamed` and leave `takenOverFrom` empty. A claim whose tenant the store cannot tell - stored before it recorded tenants - is of no tenant; - give every claim a token greater than every token issued for that thread before, also after `deleteThread`; - apply a `Commit` entirely or not at all, and only with the current claim's token (`StaleClaim`, checked first). With a `Commit.fence` of `(other, otherToken)`, apply it only if `otherToken` is also `other`'s current claim, in that same lock or transaction, the thread's own claim checked first (`StaleClaim` for `other` otherwise, writing nothing); - delete a thread only with the current claim's token, checked in the same lock or transaction as the delete (`StaleClaim` otherwise, deleting nothing); a thread with no claim is refused too. With a `fence` of `(other, otherToken)`, delete only if `otherToken` is also `other`'s current claim, in that same lock or transaction, the thread's own claim checked first (`StaleClaim` for `other` otherwise, deleting nothing); - keep, after `deleteThread`, where the thread's event numbering ended - a tombstone with no data, for a thread that numbered any event - and number the next thread of the id from one past the deleted thread's next number; refuse `eventsAfter`/`awaitEventsAfter` after a number of the deleted thread with `ThreadDeleted` (`Checkpointer.deleted(afterSeq, firstSeq)` is the rule), and read from `0`, or from just before the current thread's first event, from that first event. A store that wakes `awaitEventsAfter` on commits wakes it on a delete too; - accept a new checkpoint only over the latest (`CheckpointConflict`) and with an id not already in the history, and pending writes only for it; - keep every superseded checkpoint in the history until `prune`, a commit's `retractTurn` or `deleteThread` removes it, and keep pending writes for the latest only; - on `retractTurn`, remove every checkpoint after the newest `Completed` or `Failed` one before adding the new one; - number events contiguously inside the commit, and never reuse a number, also across `deleteThread`; - record on each event the token of the commit that stored it (`EventRecord.committed(threadId, seq, commit.token, draft)`): a run's subscriptions tell its events from an earlier run's of the same `RunId` by it; - release only with the current token, and treat any other as a no-op; - return from `awaitEventsAfter` what `eventsAfter` would: at once if there is something, and otherwise within about its `timeout`. A store that can be told of commits, such as a database with a notification channel, overrides it to return as soon as one lands. It must then wake for commits made through every store over the same storage; - tolerate the reading thread being interrupted at any point. Cancelling a subscription interrupts its watch, which may be in the middle of `eventsAfter` or `awaitEventsAfter`: that one call may fail (`CancelledError`), but the store must stay usable by every other thread. A driver whose I/O goes through a `java.nio.channels.InterruptibleChannel` (a `FileChannel` or `SocketChannel`) closes the channel when the thread is interrupted mid-read, so a store sharing one such connection between threads must read on a connection of its own for the watch, or replace a connection an interrupt closed; - keep each thread's metadata, replaced only by a commit carrying `Commit.metadata` and removed by `deleteThread`, and answer `threads` and `incompleteThreads` from the latest checkpoint (its tenant, graph id, status and `deleting`), the claim judged by your clock, and when you last committed a checkpoint or metadata to the thread - tenant-scoped, root threads only, by an index, within your bound; - return from every call within a bound you document - a configured timeout - whatever the storage is doing: waiting for a lock, another connection's write, the store's own earlier calls or the network. A call that reaches it fails, changing nothing; `awaitEventsAfter` returns within its `timeout` plus the bound. Say which calls, if any, you cannot bound, and why. Keep the bound well inside `ttl - renewEvery` (see [When the store hangs](#when-the-store-hangs)). ### Testing it with the contract suite (not compile-checked) ```scala // same version as llm4s-agent; test scope only libraryDependencies += "org.llm4s" %% "llm4s-agent-testkit" % llm4sVersion % Test ``` `CheckpointerContract` is the contract as a ScalaTest suite. Mix it into a spec and say how to make a store: (not compile-checked) ```scala import org.llm4s.agent.graph.Checkpointer import org.llm4s.agent.testkit.CheckpointerContract import org.scalatest.flatspec.AnyFlatSpec class MyCheckpointerContractSpec extends AnyFlatSpec with CheckpointerContract: protected def newCheckpointer(clock: java.time.Clock): Checkpointer = MyCheckpointer.open(freshDatabase(), clock) ``` It runs every case against your store: commits (also fenced by another thread's claim), conflicts, event numbering, compaction, `deleteThread` (fenced by the current token and by the fence thread's, and a recreated thread numbered on past the deleted one, whose log a reader cannot continue), round trips, the checkpoint history (paging, reads by id, unique ids, retraction and pruning by age, count and event count), claims and fencing (also from many threads at once), each claim's tenant (a holder named only to a claim of its own tenant, also across a reopen), and two or more `GraphRuntime`s contending over one store - a live run refused elsewhere, renewal, takeover after expiry without re-running completed tasks, a stale run's commits refused, only one of several runtimes admitted to recover a thread, `updateState` and `fork` fenced against a live run, a blocked turn leaving nothing in the history, and another tenant's claim on a thread never revealing its run to the thread's tenant through `ThreadBusy`, `RunRecovered.takenOverFrom` or `ClaimLost`. It also checks change notification: `awaitEventsAfter` itself, and subscriptions in one runtime that receive another runtime's commits in order, mid-run, across a restart, and only until they are cancelled or the thread is deleted. `InMemoryCheckpointer` passes it. Four hooks fit it to a durable store: (not compile-checked) ```scala class MyCheckpointerContractSpec extends AnyFlatSpec with CheckpointerContract: protected def newCheckpointer(clock: java.time.Clock): Checkpointer = MyCheckpointer.open(freshDatabase(), clock) // close the store and open the same storage again, as a restarted process would override protected def reopen(store: Checkpointer, clock: ManualClock): Checkpointer = store match case mine: MyCheckpointer => mine.close() MyCheckpointer.open(mine.location, clock) case other => other ``` - **`reopen`** runs the cases that check a live claim, its token and the fencing of a stale holder survive a restart. It defaults to the same instance. - **`sibling`** opens another store over the same storage, as another process would, while the first stays open: `MyCheckpointer.open(mine.location, clock)`. The change-notification cases put each runtime on a store of its own through it. It defaults to the same instance. - **`boundedStore`** gives the case that checks the bound on every call a store configured with a short one, the bound it documents, and a way to make the storage busy from outside, as another process holding a write would: `CheckpointerContract.BoundedStore(store, bound, busy)`. The case makes many calls at once while the storage is busy, and fails if one takes longer than `bound` plus `boundGrace` (500 ms). It defaults to `None`, which skips the case: right for a store whose storage nothing outside it can hold, as `InMemoryCheckpointer`'s. - **`advanceStoreClock`** is how the suite expires claims. By default it moves the `ManualClock` it handed to `newCheckpointer`, so the store must judge expiry by that `clock`. A store that can only use a clock of its own, such as a database server's `now()`, overrides it to make claims expire as if that clock had moved - by moving every stored expiry back by `by`, for example - and sets `exactExpiry = false`, so that `expiresAt` is checked by its effect rather than against the `ManualClock`. ## See also - [Running agents on several machines](multiple-machines): one agent service on several instances over the PostgreSQL store - the run queue, workers, cancel, busy-thread policy, tracing, metrics, retention and upgrades. - [Streaming events](streaming): subscribing to a thread's events, which a store numbers and replays. - `docs/design/typed-agent-runtime/`: §4.16 (`4.16-stage-2-slice-1-the-durable-checkpointer-contract.md`), the design of claims and fencing; §4.19 (`4.19-stage-2-slice-3-checkpoint-history-fork-and-retention.md`), history, fork, `updateState` and retention; and §4.21 (`4.21-stage-2-slice-6-live-subscriptions-across-runtimes.md`), subscriptions across runtimes. - [Migration guide](../../reference/migration#stage-2-migration-durable-execution): what changed for code that implemented `Checkpointer` or built a `Commit`. --- Source: https://llm4s.org/guide/agents/human-review # Human Review Pause an agent's turn for a person: to approve a tool call, answer a question, review an answer, or look at what a step did before the next one runs. --- ## Four ways a turn waits A turn that waits for a person ends `AgentStatus.Suspended`. Everything it waits for is an *interrupt* with its own `InterruptId`, in one of four lists: | List | Raised by | Answer with | |---|---|---| | `approvals` | a tool's `ToolOutcome.NeedsApproval`, or a tool wrapper such as `ApprovalMiddleware` | `result.approve(id)`, `reject(id, reason)`, `edit(id, arguments)` | | `questions` | a tool extending `AgentTool.Asking[A, Q, Ans]` | `result.reply(id, answer)`, the tool's `Ans` | | `middlewareQuestions` | a middleware extending `AgentMiddleware.Asking[Q, Ans]` | `result.reply(id, answer)`, the middleware's `Ans` | | `breakpoints` | a static breakpoint on one of the agent's nodes | `result.proceed(id)` | `agent.resume(threadId, answers)` takes any non-empty subset of them. The ones you do not answer stay pending, so the result can be `Suspended` again with what is left: this is incremental approval. An unknown id is refused and the thread is unchanged, as is an approval decision, a middleware answer or a breakpoint answer that does not decode as the asker's type. A tool question's answer is different: the tool decodes it, so one that does not decode as the tool's `Ans` is accepted, and becomes that call's error result (`Invalid answer for '': ...`), which the model sees. While a thread waits, `run` and `recover` on it are refused with `PendingInterrupts`. (not compile-checked) ```scala agent.run(threadId, "Deploy the release").flatMap { result => result.status match case AgentStatus.Suspended(approvals, questions, asked, held) => val answers = approvals.map((id, _) => result.approve(id)) ++ held.map((id, _) => result.proceed(id)) agent.resume(threadId, answers.toMap) // questions and middleware questions stay pending case _ => Right(result) } ``` The waiting thread is persisted before `run` returns, under every durability mode, so another process with the same agent and checkpointer can resume it. ## Static breakpoints A breakpoint holds a task of one of the agent's nodes, before it runs or after it ran: (not compile-checked) ```scala val agent = Agent .builder("assistant", client) .withTools(tools) .withInterruptBefore(AgentNode.Tool) // each tool call, before it runs .withInterruptAfter(AgentNode.Model) // each model call, after its message is stored .build() ``` | `AgentNode` | Node | Before | After | |---|---|---|---| | `Model` | `/model` | the model is not called yet | the model's message is stored; its tool calls, or the answer's guarding, wait | | `Tool` | `/call-tool` | the call has not run | its result is recorded; the next model call waits for it | | `Finish` | `/finish` | the answer is stored; its `afterAgent` hooks (guardrails) have not run | the answer is guarded; the turn ends once answered | Each held task is an interrupt of its own. The tool calls of one message are held one per call, so a reviewer can let them through one at a time. `AgentStatus.Suspended.breakpoints` lists them as `BreakpointRequest(node, phase, call)`; `call` is the tool call, for a call held before it runs. `result.proceed(id)` continues one: a task held before it runs is run, and the same breakpoint does not hold it again; a task held after it ran is not run again - what follows it is released. A breakpoint takes no other answer. A step continued after a review runs at another node than the step itself: a model call after a `wrapModelCall` question at `/asked//wrapModelCall`; a tool call after an approval or a question at `/approval`, `/ask/` or `/asked//wrapToolCall`; an answer guarded again after an `afterAgent` question at `/asked//afterAgent` (the root's, for a root middleware asking about a handoff target's answer). A breakpoint *after* `Model`, `Tool` or `Finish` holds those nodes too, so nothing the step does escapes it: a model message the gate let through still waits before its tool calls run, and a result recorded after an approval still waits before the model sees it. `BreakpointRequest.node` names the node the step ran at. A breakpoint *before* holds only the step's own node: a continued step was just reviewed. Each task held after it ran is released in a superstep of its own, which replays its routes without running the node again. That superstep counts against the run's `RunBudgets.maxSupersteps`, so a run with many held steps may need a larger budget. Breakpoints are not part of the agent's graph version, like streaming: a thread an agent holds can be continued by the same agent built without them. A run can add its own with a `RunConfig`, naming the node ids: `agent.run(threadId, query, RunConfig(interruptBefore = Set(NodeId("assistant/model"))))`. A node the agent does not have is refused with a `ValidationError` before the thread is touched. ### On any graph Breakpoints belong to the graph runtime, not only to agents. A `RunConfig`'s `interruptBefore` and `interruptAfter` name nodes of any compiled graph; each `start`, `resume` and `recover` brings its own. A held task is parked like a task that returned `NodeResult.Suspend`, keyed by its task id, and the run pauses at the end of the superstep - its siblings run and commit. `RunResult.Suspended` reports it as a `PendingInterrupt` with `breakpoint = Some(BreakpointPhase.Before)` (its `question` is the task's input) or `Some(BreakpointPhase.After)` (the node's update is committed; its routes, static edges and join arrivals wait). Answer it with `Breakpoint.proceed`: (not compile-checked) ```scala val config = RunConfig(interruptAfter = Set(NodeId("plan"))) for held <- runtime.start(threadId, graph, input, config).flatMap(_.await()) resumed <- runtime.resume(threadId, graph, Map(InterruptId("0.0") -> Breakpoint.proceed), config) done <- resumed.await() yield done ``` Cancelling a run is unaffected: a cancelled run leaves its thread for `recover`, which holds a task the breakpoint would hold, and runs a continuation that already passed its breakpoint without holding it again. ## Questions from middleware A middleware that needs a person - to review an answer, to edit it, or to supply missing information - extends `AgentMiddleware.Asking[Q, Ans]`, as an asking tool extends `AgentTool.Asking`. A hook asks by returning `ask(question)` (from `beforeAgent`, `afterAgent` or `wrapModelCall`) or `askAbout(question)` (from `wrapToolCall`), and reads its answer with `answered(context)` when it runs again: (not compile-checked) ```scala final case class Need(what: String) derives ReadWriter final case class Info(value: String) derives ReadWriter final class Region extends AgentMiddleware.Asking[Need, Info]: val id = MiddlewareId("region") override def beforeAgent(input: String, context: ModelContext): Result[BoundaryResult] = if input.contains("region") then Right(BoundaryResult(input)) else answered(context).fold(ask(Need("region")))(r => Right(BoundaryResult(s"$input (region ${r.answer.value})"))) ``` The turn suspends with a `MiddlewareQuestionRequest` - the agent and middleware that asked, the hook, and the question as JSON (read it with `ToolLoop.middlewareQuestion[Need](request)`). Once answered with `result.reply(id, Info("eu"))`, the asking task runs again from its start, and the whole stack of that hook runs again from the outermost middleware; nothing about a stack's position is stored. What runs again depends on the hook: | Hook | While it waits | Once answered | |---|---|---| | `beforeAgent` | nothing of the turn is stored | the turn's input runs again | | `wrapModelCall` | nothing of the model call is stored, but a call the wrapper made before asking is counted in usage | the model step runs again: a wrapper that asked after calling `next` calls the model again, unless it returns a completion of its own | | `wrapToolCall` | the call has no result | the call runs again: its arguments are checked again, with the approval it had | | `afterAgent` | the answer is stored, the turn has no outcome yet | the answer is guarded again | Answers given earlier in the same stack run are kept while it runs again, so two asking middleware in one stack each ask once, and a tool call's answer is kept through a later approval or tool question. Because the stack runs again, a middleware outside the asking one runs twice and must not depend on running once. A tool wrapper's questions are one per call, so the calls of one message can be answered one at a time. A middleware that asks while the tool continues after its own question gets an error result instead, since running the call again would lose the tool's answer. ### Reviewing what guardrails refuse `GuardrailReviewMiddleware(input, output)` runs guardrails as `GuardrailMiddleware` does, but a refusal asks a reviewer instead of blocking. The question is a `GuardrailReview(phase, guardrail, reason, text)`; the answer a `GuardrailVerdict`: `Allow` lets the text through, `Edit(text)` replaces it, and `Block` blocks the turn as `GuardrailMiddleware` would have (`AgentStatus.Blocked`). A verdict holds for the text and phase it was given for, whatever the refusal's wording: once given, the guardrails do not run on that text again, so an LLM judge whose reason varies from call to call is asked about once and is not called again. ## From Java and Kotlin `JAgentStatus.pending()` lists every interrupt as a `PendingInterrupt`, approvals first, then tool questions, middleware questions and breakpoints. `kind()` is the Java enum `InterruptKind`: `APPROVAL`, `QUESTION`, `MIDDLEWARE_QUESTION` or `BREAKPOINT`. A field that only some kinds have is an `Optional`: `toolName()` and `argumentsJson()` when a tool call waits; `reason()` for an approval; `questionJson()` for a question; `middleware()` for a middleware question; `node()` and `phase()` (the Java enum `BreakpointPhase`, `BEFORE` or `AFTER`) for a breakpoint. `Answer.proceed(id)` continues a breakpoint; `Answer.reply(id, json)` answers either kind of question. An agent with breakpoints or asking middleware is built with `Agent.builder` and wrapped with `Llm4s.wrapAgent`. See [Suspended turns from Java and Kotlin](index#suspended-turns-from-java-and-kotlin). --- Source: https://llm4s.org/guide/agents/delegation # Delegation to subagents Hand a bounded task to another agent and get back only its typed report. --- ## Overview A **subagent** is a `TypedAgent[I, O]`: an agent that takes a typed input `I` and gives back a typed report `O`. A parent offers its subagents with `AgentBuilder.withSubagents`, and its model delegates through the `task` tools. Each delegated task runs on a **child thread** of the parent's thread, with a fresh conversation and only the child's own tools and middleware, plus what its [inheritance](#inheritance) grants. Only typed data crosses: the parent sends `I`, which becomes the child's first user message, and gets back `O`. The child's messages never enter the parent's thread, and the parent's never enter the child's. The API is `@Experimental` (design [§4.44](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.44-stage-4-slice-d1-typed-agent-and-task.md), [§4.45](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.45-stage-4-slice-d2-inheritance.md)). ## Defining subagents (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.agent.delegation.{ DelegationConfig, TypedAgent } import org.llm4s.schema.SchemaOf final case class ResearchQuestion(topic: String) derives ReadWriter, SchemaOf final case class Findings(summary: String, sources: Vector[String]) derives ReadWriter, SchemaOf // a typed subagent: its own tool, task_researcher, whose arguments are ResearchQuestion's schema val researcher = TypedAgent("researcher", "Researches one topic and cites its sources", SchemaOf[ResearchQuestion].definition, SchemaOf[Findings].definition) .withSystemPrompt("You research one topic. Answer with your findings.") .withTools(researchTools) // a text subagent: offered through the shared `task` tool, {subagent_type, description} val general = TypedAgent.text("general-purpose", "Any self-contained task") for lead <- Agent.builder("lead", client) .withSubagents(researcher, general) .withDelegation(DelegationConfig().withMaxConcurrentChildren(2)) .build() result <- lead.run(ThreadId("brief-1"), "Write a brief on tidal power") yield result.answer ``` - **`task`** takes `{subagent_type, description}` and covers every subagent made with `TypedAgent.text` (or whose input is a `TaskRequest`), so prompts written for the Deep Agents `task` tool carry over. - **`task_`** is the tool of each other subagent; its arguments are the subagent's input schema. - A cacheable prompt section lists each subagent's id, tool and description. A text subagent's report is its answer. A typed subagent answers in `O`'s schema (`AgentBuilder.withOutput`), and its report is that answer decoded. The child uses the parent's client and completion options unless `withModel` sets others: a model is not a capability, so it is inherited. ## Inheritance A child takes nothing of its parent's unless granted. `withInheritance` says what it takes: (not compile-checked) ```scala import org.llm4s.agent.delegation.{ Inheritance, TaskBudget, ToolGrant } import scala.concurrent.duration.* researcher.withInheritance( Inheritance() .withTools(ToolGrant.Only(Set("fetch_page"))) // or ToolGrant.AllExcept(...), ToolGrant.None (default) .withMiddleware(MiddlewareId("semantic-memory")) // the parent's instance: shared memory, same tenant .withBudget(TaskBudget().withMaxSteps(20).withTimeout(2.minutes).withMaxTokens(50_000)) .withDelegate(false) // its own subagents only when true, to maxDepth ) ``` - **Tools and middleware** are the parent's instances, run against the child's thread. A tool over a `StateFileBackend` sees the child's own (empty) files; one over a host, workspace or store backend shares the parent's, so give parallel children distinct paths. A name the parent does not have is refused at `build()`. The task tools are never granted as tools: `withDelegate(true)` lets a child offer its own `withSubagents`, and only while its depth is below `maxDepth`. - **Identity is never granted.** Tenant, principal, tracing, the runtime and the checkpointer are always the parent's. - **Budgets** are capped by what the parent has left when the task is called: steps by the parent turn's remaining steps (`RunContext.remainingSteps`; the parent's `maxSteps` when the budget sets none), tokens by the parent run's `maxTokens` less its usage, time by the parent's deadline, concurrency by the parent's `maxConcurrency`. A parent with no steps or tokens left starts no child: the call is refused (`TaskOutcome.NotStarted`) naming only the subagent. A parent past its deadline fails the call with its own `DeadlineExceeded`. Each limit is the child's whole life: a child recovered after a crash has its grant less what it used, and its time runs from its first start. Parallel children each get the same remainder, so together they can overshoot the parent's tokens; the parent's next model call then stops at `TokenLimitReached`. `Agent.delegation` is the `DelegationPlan`: for each subagent its tools (own or granted), middleware in order, the ancestors' permission rules, its model and whether it is inherited, its budget, and whether it may delegate. Log it with `toString`. ## Bounds `DelegationConfig` bounds delegation, each limit on its own: | Setting | Default | What it bounds | |---|---|---| | `maxConcurrentChildren` | 4 | children one parent run has live at once; a call over it waits | | `maxTasksPerTurn` | 16 | task calls per parent turn, by their order in the turn's messages, a handoff's agents' included; a call over it is a tool error | | `maxDepth` | 1 | how deep delegation nests | | `maxReportBytes` | 64 KiB | every task tool message, at least 64 bytes: a larger report is a tool error, and an error's reason is cut to fit | | `childRetention` | 64 | settled child threads each parent thread keeps | Task calls in one model message run in parallel, as any tool calls do, bounded by the run's `maxConcurrency` and then by `maxConcurrentChildren`. ## Subagents in a harness A `HarnessProfile` carries subagents too, as `subagents: SubagentConfig` ([design §4.48](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.48-stage-4-slice-p1-harness-subagents.md)). `HarnessProfile()` has `SubagentConfig.none`: no subagents, because delegation is never granted silently. `HarnessProfile.deepAgents` turns on the **general-purpose subagent**, `general-purpose`, which the model calls through `task` like any `TypedAgent.text` subagent: - Its own harness is the minimal profile: the file tools over its own `StateFileBackend`. Its files are its own; only its report comes back. - Its grant is every tool of the parent except the file tools and `execute`, and no delegation. The preset computes it from the parent's actual tools when the agent is built, so a `deepAgents` agent with or without a sandbox builds. `Agent.delegation` shows it. (not compile-checked) ```scala import org.llm4s.agent.harness.{GeneralPurposeConfig, HarnessProfile, SubagentConfig} val profile = HarnessProfile.deepAgents // specialists only, no general-purpose subagent val specialists = profile.withSubagents(SubagentConfig.none.withSpecialists(researcher, critic)) // change the general-purpose subagent: its model, description, prompt or grant val cheaper = profile.withSubagents( profile.subagents.withGeneralPurpose(GeneralPurposeConfig().withModel(smallClient, CompletionOptions())) ) // the bounds val wider = profile.withSubagents(profile.subagents.withDelegation(DelegationConfig().withMaxConcurrentChildren(8))) ``` - The profile's subagents come first, then any added with `AgentBuilder.withSubagents`. `SubagentConfig.delegation` bounds them all; `withDelegation` beside a harness is refused. - The `delegation` middleware runs inside the harness's permissions, so with permission rules `task` needs an `Invoke` rule (`task:general-purpose`). - The `deepAgents` prompt gains a part on delegating: independent, bounded work, a self-contained description, parallel tasks in one message, and checking a report before relying on it. - `llm4s.agent.harness.subagents` in `reference.conf` holds `generalPurpose.enabled` and the `DelegationConfig` bounds, which `HarnessConfigLoader` reads. Specialists and models are code. ## When a child does not report A child that is blocked by a guardrail, reaches its step limit, gives a typed answer that fails its schema twice, or fails, gives no report. With `ChildFailure.Report`, the default, the parent model sees a tool error naming the subagent, the child thread and the reason, and can compose without it. With `withFailure(ChildFailure.Fail)` the parent run fails instead, and the parent's `recover` continues the child from its own checkpoint. A task call runs again on `recover`. It never starts its child twice: a completed child's report is read back from the child thread, and a child that was still running continues from its checkpoint. ## Approvals and questions through the parent A child that stops for an approval, a tool's or a middleware's question, or a breakpoint suspends its parent too. The parent's other calls in the batch finish first. The parent's `AgentStatus.Suspended` lists each child interrupt in `delegated`, under a composite id `~` (a grandchild's is `p~c~g`), with the `ChildRef` of the child that waits and its request as the child gives it (`ChildInterrupt`). A delegated interrupt is never also in `questions`. Answer them with the usual helpers, by the composite id, and resume the parent: (not compile-checked) ```scala agent.run(threadId, "research tides").flatMap { result => result.status match case AgentStatus.Suspended(_, _, _, _, delegated) => val answers = delegated.map { case (id, DelegatedRequest(_, _: ChildInterrupt.Approval)) => result.approve(id) case (id, DelegatedRequest(_, _: ChildInterrupt.Breakpoint)) => result.proceed(id) // a tool's or a middleware's question: reply with its answer type (here a tool's `UnitChoice`) case (id, DelegatedRequest(_, _)) => result.reply(id, UnitChoice("km")) } agent.resume(threadId, answers.toMap) case _ => Right(result) } ``` `approve`, `reject`, `edit`, `reply` and `proceed` encode the answer the child expects; `Agent.resume` routes it to the child through its `task` call, which resumes the child and reports to the parent model once the child ends. A rejection reaches the child's model as at top level. Answer some and the rest stay pending: the call asks again, under a new parent interrupt id. A composite id that is not pending is refused with `GraphError.InvalidResume`. A parent that stops after its child resumed never resumes the child twice: its `recover` reads the child first. ## Permissions Each task call's resource is `(Invoke, task:)`, so a `PermissionMiddleware` on the parent can allow, ask or deny each subagent: (not compile-checked) ```scala new PermissionMiddleware(Seq(PermissionRule.allow(Operation.Invoke, "task:researcher"))) ``` Permissions only narrow. A child's call runs only if every ancestor's `PermissionMiddleware` rules allow it, and then the child's own: a `Deny` at any level denies, and an `Ask` at any level asks once, the approval covering every level. So rules on the parent are the whole policy for the subagents below it too: allow there what a child's tools touch. ## Inspecting children `Agent.children(threadId)` lists the child threads a thread's tasks ran on, each with its origin and status. Read a child's history through the runtime by its id. A child thread cannot be run, resumed or forgotten directly; forgetting its parent forgets it. ## Following children Every `task` call sends the parent's live `SubagentEvents.TaskStarted` as its child starts, and commits one durable `SubagentEvents.TaskEnded` with its result: the child, a status label (`completed`, `blocked:`, `step_limit_reached`, `token_limit_reached`, `output_invalid`, `failed`; a call waiting on a suspended child commits none until the child settles), the child's whole usage and its duration. It carries no request, report or reason, so it is the event to count, time and cost subagents by. A subscription made with `nested = true` also receives every event of each child, as `StreamEvent.Nested(child, event)`, after the child's `TaskStarted` and before its `TaskEnded`; a grandchild's arrive nested twice. Match them with `EventHandlers.onChild`, which gives the innermost child: (not compile-checked) ```scala agent.stream(threadId, "Research these three topics", nested = true)( EventHandlers .on(SubagentEvents.TaskStarted)(s => println(s"-> ${s.child.agent}")) .onChild(AgentEvents.TextDelta)((child, d) => print(s"[${child.agent}] ${d.text}")) .on(SubagentEvents.TaskEnded)(e => println(s"<- ${e.child.agent}: ${e.status} in ${e.duration}")) ) ``` A child's text deltas arrive only if the subagent streams (`TypedAgent.withStreaming()`). Nested events are live: they take the subscription's live slots, so a slow listener loses them as a `LiveGap`, and a child this process cannot watch - one another process holds, or one that had already finished when a replay reattached to it - shows as one `Nested(child, LiveGap(0))`. Its durable events are kept: `agent.subscribeChild(ref)` replays and follows one child's own log, tenant-checked like any read of a thread. The cats-effect, ZIO and Pekko modules offer the merged stream as `nestedEvents(run, handlers)`. A subagent traces to its parent's `Tracing`; its `AgentRunEnded` names the parent thread and run (`parentThreadId`, `parentRunId`), which OpenTelemetry puts on the `invoke_agent ` span and Langfuse in the run's metadata. The child's run is in its parent's trace, under the `execute_tool task` span of the call that started it - in Langfuse too, with no trace of its own - and stays there when a later run of the parent recovers or resumes it: its `RunOrigin.trace` records that call's span (design 4.58). ## Running a subagent alone `typedAgent.bind(client, runtime)` compiles a definition (pass `options` for non-default `CompletionOptions`; `withModel` overrides both); `BoundAgent.run(input)` runs it once on a new top-level thread and returns its `TaskOutcome`, for tests and one-off calls. A graph node calls `bound.run(threadId, input)` instead, with a `threadId` derived from its own replay-stable key (one that identifies the input too): a thread already there is reattached, never run twice - a settled one returns its stored outcome with no model call, a failed or crashed one is recovered without repeating completed tool effects, a suspended one returns `Suspended` again, and one another caller is running is waited on. `bound.resume(outcome.child, answers)` continues a run that came back `Suspended`, taking the same answers as `Agent.resume`, and `bound.recover(outcome.child)` one that came back `Refused(TaskRefusal.Failed(_))`; both return a `TaskOutcome` too. Only a standalone run's ref is accepted: a delegated child is continued through its parent. ## A worked example [`ParallelDeepResearchExample`](/examples/#parallel-deep-research) puts these together: a lead on `HarnessProfile.deepAgents` with the general-purpose subagent off, a typed `researcher` granted the lead's `fetch_page` and a tool-less `critic`, `maxConcurrentChildren = 2`, and permission rules that ask before a fetch from an unknown site, wherever in the tree it is made. Its acceptance suite, `ParallelDeepResearchAcceptanceSpec`, runs it on a scripted model through a parallel batch, a crash and `recover`, a child's failure, a child's approval, a cancel, the merged stream and `forget` ([design §4.49](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.49-stage-4-slice-p2-exit-example.md)). ## Delegation or handoffs A [handoff](handoffs) moves the conversation to another agent of the same family, which then owns it. Delegation starts another thread, and the parent keeps control and sees only the report. Use handoffs for conversational routing, delegation for bounded, independent work, especially work that can run in parallel. --- Source: https://llm4s.org/guide/agents/multiple-machines # Running agents on several machines Run one agent service as several instances that share threads through one PostgreSQL database: any instance can start, follow, cancel or delete any thread, a run whose machine dies is picked up by another, and one database is the whole deployment. --- ## The shape of it llm4s ships the *service kernel*, not an HTTP server: everything that touches the store's guarantees - claims, fencing, the run queue, the worker, retention - is in `llm4s-agent` (`org.llm4s.agent.service`) and the store modules, and your service puts its own endpoints, authentication and protocol in front of it. Each instance runs: - a **store** over the shared database (`PostgresCheckpointer`, `llm4s-agent-checkpoint-postgres`), which holds every thread's checkpoints and events and the **run queue**; - a **`RunSubmitter`** that your endpoints call to queue a run, follow its events, cancel it or delete a thread; - an **`AgentWorker`** that takes queued runs, runs them on your agents, recovers runs whose worker died, runs the retention sweep and drains on shutdown. ``` HTTP / gRPC / your protocol ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ instance A │ │ instance B │ │ instance C │ │ RunSubmitter │ │ RunSubmitter │ │ RunSubmitter │ │ AgentWorker │ │ AgentWorker │ │ AgentWorker │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └────────────────┼────────────────┘ PostgreSQL (one schema): checkpoints, events, claims, run queue ``` There is no Redis, broker or coordinator: instances never talk to each other, only to the database, and the database's clock decides every claim, lease and retention age, so clock skew between machines changes nothing. A complete, runnable example is the [example agent service](#the-example-deployment); the stage's exit test runs three of it against one Postgres and kills one mid-run. ## The store Every instance opens the same schema. With `llm4s-agent-checkpoint-postgres` on the classpath, configuration alone chooses it: ```hocon llm4s.agent.checkpointer { kind = "postgres" # or LLM4S_CHECKPOINTER postgres { url = "jdbc:postgresql://db:5432/app" # or LLM4S_CHECKPOINTER_POSTGRES_URL user = "agents" # or LLM4S_CHECKPOINTER_POSTGRES_USER # password: LLM4S_CHECKPOINTER_POSTGRES_PASSWORD, never in a checked-in file schema = "agents" callTimeout = 5 seconds connectionTimeout = 5 seconds maxPoolSize = 10 } } ``` (not compile-checked) ```scala for store <- CheckpointerConfigLoader.open() // close it when the process stops runtime <- RuntimeConfigLoader.runtime(store.checkpointer) queue <- store.runQueue // the queue lives in the same schema yield (store, runtime, queue) ``` - Instances that start together migrate the schema once, under an advisory lock. - Every store call returns within `connectionTimeout + callTimeout`. Keep that well inside the claim's `ttl - renewEvery` (below), so a slow database makes a run lose its claim cleanly rather than hang. - Each instance holds up to `maxPoolSize` connections plus one listening connection (commits are announced with `LISTEN`/`NOTIFY`, so a run committed on one machine reaches a subscriber on another at once). Size the database's `max_connections` for every instance. The store's details - fencing, notification, the registry - are in [Durable Checkpointers](durable-checkpointers#the-postgresql-store). ## The queue and the workers A run is **submitted** to the queue and **executed** by whichever instance's worker takes it. The queue entry, not the instance that accepted the request, owns the run: it records each attempt's `RunId` before the attempt touches the thread, so a run is attempted at least once and applied at most once, however many workers try it. (not compile-checked) ```scala // every instance for agent <- Agent.builder("assistant", client).withRuntime(runtime).build() settings <- RunQueueConfigLoader.load() // llm4s.agent.queue workerCfg <- WorkerConfigLoader.load() // llm4s.agent.worker worker <- AgentWorker(queue, store.checkpointer, Seq(QueuedAgent(agent)), workerCfg, settings, metrics) submitter = new RunSubmitter(queue, store.checkpointer, settings, metrics) yield worker.start() sys.addShutdownHook { worker.shutdown(); store.close() } // drain, then close submitter // an endpoint, on any instance submitter.submit(ThreadId("t-42"), AgentId("assistant"), QueuedInput.query("Summarise the report"), Some(tenant)) // -> Ticket(entryId, threadId, position, existing) ``` - **Every instance runs the same agents under the same ids.** A worker takes only entries for the agent ids it was built with; a typed agent goes in as `QueuedAgent.typed(boundAgent)`. - **Following a run** is `submitter.follow(tenant, entryId, after, limit, wait)`, a page at a time from a thread sequence number: it waits for the next commit on whichever machine makes it, and resuming from the last number seen repeats nothing. `submitter.await(tenant, entryId, timeout)` waits for the entry to close. - **A machine that dies mid-run** leaves its entry's lease and its thread's claim to expire. Another worker then takes the entry, records a new attempt and recovers the thread from its last checkpoint; a follower sees the recovered run's events continue the same log, with no gap and no repeat. Recovery takes about the longer of the lease and claim `ttl`s. - **Attempts are bounded** by `llm4s.agent.queue.maxAttempts`; an entry that runs out is closed `failed` (a dead letter), and so is one no worker's graph version can run for `unrunnableAfter`. - **Submission keys** make a retried request idempotent: the same `submissionKey` from the same tenant returns the first entry's ticket. ```hocon llm4s.agent { runtime.claims { ttl = 30 seconds, renewEvery = 10 seconds } # label: LLM4S_RUNTIME_LABEL queue { maxAttempts = 5 ticketLifetime = 24 hours # how long a closed entry stays readable lease { ttl = 30 seconds, renewEvery = 10 seconds } } worker { concurrency = 4 # runs one worker has at once pollInterval = 1 second drain { grace = 30 seconds, exitTimeout = 10 seconds } label = ${?LLM4S_WORKER_LABEL} # the pod or host name: it names the lease holder in logs } } ``` **Shutdown drains.** `worker.shutdown()` stops taking entries, lets running runs reach rest within `drain.grace`, then cancels the rest and gives their entries back, so another instance recovers them at once instead of waiting for the lease to lapse. Give the process `grace + exitTimeout` plus a margin to stop (Kubernetes' `terminationGracePeriodSeconds`). **Readiness is `worker.health()`**: the worker is running, its loop is alive and the store answers. Keep liveness on the process alone: restarting instances because the database is down turns an outage into a crash loop. ## Cancel and the busy-thread policy **Cancel from any machine** with `submitter.cancel(tenant, CancelTarget.Entry(entryId), note)`, or `CancelTarget.Thread(threadId)` for every open entry of a thread. An entry not yet started closes at once; a running one is stopped by the worker holding it, on whichever machine, when it next renews the entry's lease (so within `lease.renewEvery`): the run is interrupted, its thread settled, and the entry closes `cancelled` - or with the run's own outcome if it finished first. `submitter.deleteThread(runtime, tenant, threadId, timeout)` cancels the thread's entries, waits for them to close, and then deletes it. **A message for a busy thread** - one with an open entry or a live run - follows the submission's `policy`: | `BusyThreadPolicy` | What happens | |---|---| | `Reject` (default) | Refused with `GraphError.ThreadBusy`; the caller retries later. | | `Enqueue` | Queued behind the thread's entries; runs after them, in submission order. | | `Interrupt` | Queued ahead of them. The running queued run stops at its next superstep boundary: the model call in flight is cut, tool calls already started finish and keep their results, unstarted ones get a synthetic "interrupted" result, so the history stays valid for the provider. Its entry closes `interrupted` and the new message runs next. | A run started directly (`agent.run`, outside the queue) is never interrupted or adopted by a worker: it is its owner's, who calls `recover` after a crash. Pending approvals or questions are not a busy thread: answer them with a `QueuedInput.Resume`. ## Tracing A run is **one trace across every machine that executes it.** Pass the caller's position as the submission's `traceContext` (for HTTP, parse the request's W3C `traceparent` header with `TraceContext.parse`); the entry stores it, and every attempt continues it - the first, and a recovery on another machine after a crash. Each checkpoint records the trace it belongs to (`Checkpoint.trace`), and span ids are derived from the trace id and the run's identity, so two machines name the same spans without coordinating. Spans are emitted by the agent's tracing: build the agent `withTracing(tracing)` with a backend from `llm4s-observability-otel` (`TRACING_MODE=opentelemetry`) or `llm4s-observability` (Langfuse). Delegated child runs sit under the tool call that started them. No message content goes into a span attribute. ## Metrics Give the worker and a `MeteredCheckpointer` the same `MetricsCollector` (`PrometheusMetrics` from `llm4s-observability-prometheus`, or `MicrometerMetrics` from `llm4s-observability-micrometer`): (not compile-checked) ```scala val metrics = new PrometheusMetrics(registry) val checkpointer = MeteredCheckpointer(store.checkpointer, metrics) // runs, claims, commits, child tasks val runtime = RuntimeConfigLoader.runtime(checkpointer) // AgentWorker(..., metrics) and new RunSubmitter(..., metrics): the queue ``` | Area | Metrics (`llm4s.agent.*`; Prometheus writes `_` for `.`) | |---|---| | Runs | `runs.started`, `runs.ended` (by outcome), `run.duration` | | Claims | `claims` (acquired, taken over, busy, failed), `claim.renewals` (renewed, lost, failed) | | Commits | `commit.duration` | | Queue | `queue.wait`, `queue.depth`, `queue.oldest.seconds`, `queue.attempts`, `queue.plans`, `queue.deferrals`, `queue.lease.expiries`, `queue.lease.lapses`, `queue.lease.losses`, `queue.closed` (by outcome and reason; dead letters are `failed` / `attempts_exhausted`) | | Child tasks | `tasks.started`, `tasks.ended`, `task.duration` | | Retention | `sweep.passes`, `sweep.threads.examined`, `sweep.tombstones.reclaimed`, `sweep.entries.reclaimed` | | Shutdown | `worker.drained` | Labels take values from fixed sets (and a graph's id), never thread ids or content. Alert on `queue.oldest.seconds` (work waiting with no worker taking it), `queue.lease.expiries` (machines dying mid-run) and dead letters. ## Retention The database stays bounded only if something removes old data. The worker runs the store-wide retention sweep on a schedule, one instance at a time: ```hocon llm4s.agent.worker.sweep { enabled = true every = 5 minutes batchSize = 100 tombstoneAge = 7 days # unset: tombstones are kept retention { maxAge = 30 days, maxCheckpoints = 20, maxEvents = 10000 } # unset keys remove nothing } ``` - Each pass prunes every thread by `retention`, reclaims the tombstones of threads deleted more than `tombstoneAge` ago, and reclaims closed queue entries older than `llm4s.agent.queue.ticketLifetime`. - A thread with an open queue entry is still pruned, but keeps the newest checkpoint each of the entry's attempts committed and its tombstone, so a run in flight can always be recovered once. - **A reader of a reclaimed thread** - a client resuming an old event stream - is answered `GraphError.ThreadDeleted`, before and after reclamation, and also once a new thread reuses the id: it is never shown the new thread's events as a continuation of the old one. Its correct action is to read from sequence `0` to follow whatever is under the id now. A ticket whose entry was reclaimed answers `QueueError.TicketExpired`. Ages are judged by the database's clock against the time it committed each row, never a producer's timestamp. ## The example deployment `modules/deploy-service` is an example agent service on this kernel (unpublished; copy it rather than depend on it): submit a run, follow it over Server-Sent Events from a sequence number, cancel it, read and list threads, with readiness, Prometheus metrics and SIGTERM draining. `deploy/overlays/postgres` runs three replicas of it against one Postgres, with a PodDisruptionBudget. [`deploy/README.md`](https://github.com/llm4s/llm4s/blob/main/deploy/README.md) has the endpoints, the environment variables and the `kubectl` steps. Its exit test, `MultiMachineExitSpec` in `modules/it` (`sbt testIntegration`), starts three processes of it on one schema and checks, through their HTTP endpoints: a run submitted through one instance, executed by another, streamed live through a second and replayed from any sequence number through a third; the executing instance killed with `kill -9` and the run recovered elsewhere with an unbroken stream, in one trace; `Enqueue`, `Interrupt` and a cross-machine cancel; and a reclaimed tombstone answering `ThreadDeleted`. ## Upgrades Whether instances can be replaced one at a time depends on what the release changes: - **Same store schema and checkpoint format: rolling upgrade.** Replace instances one at a time. Each old instance drains on SIGTERM and gives back what it could not finish; new instances take it. If the agent's graph changed, a run in flight at the old graph version is passed over by new workers until an old one takes it, and closed `failed` if none does within `llm4s.agent.worker.unrunnableAfter` (1 hour by default): keep at least one old instance until its runs drain. - **A new store schema version or checkpoint format: full stop.** Stop every instance, then start the new release. The first new instance migrates the schema; an old instance that meets the newer schema refuses to open with `GraphError.UnsupportedStoreSchema` and writes nothing, so a mixed fleet fails safe rather than corrupting threads. The release notes and the [migration guide](../../reference/migration) say when a release changes either. Expand-then-contract migrations, which would let schema changes roll too, are planned with the 1.0 compatibility baseline. ## Limits - **One database.** The SPI is open to another store, but PostgreSQL is the only multi-machine store shipped. - **No HTTP server product.** Endpoints, authentication, Agent Protocol, A2A and AG-UI belong to the service you build on the kernel; the example service shows one shape and authenticates nothing (its `X-Tenant-Id` header is not auth). - **Not yet:** a shared Postgres long-term memory store, cron and webhooks, versioned assistants, checkpoint encryption at rest, background subagents. See the [parity matrix](../../reference/agent-platform-parity) for where each went. ## See also - [Durable Checkpointers](durable-checkpointers): claims, fencing, recovery, the PostgreSQL store, retention and the thread registry. - [Streaming events](streaming): the event log a follower reads. - [Agent platform parity](../../reference/agent-platform-parity): llm4s against LangGraph Platform and Deep Agents. - `docs/design/typed-agent-runtime/` 4.50-4.61: the Stage 5 design records. --- Source: https://llm4s.org/guide/patterns/ # Real-World Application Patterns Learn production-grade patterns for building scalable, reliable LLM applications with LLM4S. These guides cover architecture decisions, implementation strategies, and best practices based on real-world deployments. ## Table of Contents 1. **[Multi-Agent Orchestration](./multi-agent-orchestration)** - Design patterns for agent-to-agent communication, delegation, and failure handling 2. **[RAG for Enterprise](./rag-enterprise)** - Production strategies for document management, hybrid search, and quality assurance 3. **[Production Monitoring](./production-monitoring)** - Comprehensive observability, alerting, and cost tracking in production 4. **[Scaling Strategies](./scaling-strategies)** - Techniques for handling high throughput, caching, and distributed execution 5. **[Error Recovery](./error-recovery)** - Resilience patterns including retries, circuit breakers, and graceful degradation 6. **[Security Best Practices](./security-best-practices)** - API key management, input validation, and audit logging --- ## Getting Started Choose a pattern based on your current challenge: | Challenge | Guide | Key Topics | |-----------|-------|-----------| | Multiple agents need to work together | [Multi-Agent Orchestration](./multi-agent-orchestration) | Delegation, handoffs, context passing | | Building with documents and retrieval | [RAG for Enterprise](./rag-enterprise) | Ingestion, search, quality | | Need production visibility | [Production Monitoring](./production-monitoring) | Metrics, alerts, dashboards | | Handling high request volume | [Scaling Strategies](./scaling-strategies) | Caching, batching, distribution | | Dealing with failures | [Error Recovery](./error-recovery) | Retries, fallbacks, circuit breakers | | Protecting sensitive data | [Security Best Practices](./security-best-practices) | Secrets, validation, audit | --- ## Architecture Decision Trees ### How Should Agents Communicate? ``` Need agents to coordinate? ├─ Synchronous response required │ └─> Direct delegation (see handoff pattern) ├─ Fire-and-forget updates │ └─> Event-based communication └─> Complex multi-step workflows └─> Orchestrator agent pattern ``` ### Which Search Strategy? ``` Choosing RAG search approach? ├─ Fast, simple queries │ └─> Vector search only ├─ Mixed structured/unstructured │ └─> Hybrid search (keyword + vector) ├─ Complex business logic │ └─> Multi-stage retrieval └─> Real-time freshness critical └─> Time-aware search with reranking ``` ### Error Recovery Strategy? ``` API call failed, what now? ├─ Transient error (timeout, rate limit) │ └─> Exponential backoff retry ├─ Model unavailable │ └─> Fallback to alternate model ├─ Entire provider down │ └─> Circuit breaker + cached response └─> Recoverable from degraded mode └─> Graceful degradation ``` --- ## Pattern Comparison Matrix | Pattern | Complexity | Resilience | Performance | When to Use | |---------|-----------|------------|-------------|------------| | **Synchronous delegation** | Low | Medium | High | Simple sequential workflows | | **Event-based agents** | Medium | High | High | Loosely coupled systems | | **Vector-only search** | Low | High | Very High | Semantic similarity priority | | **Hybrid search** | Medium | High | Medium | Balanced search needs | | **Exponential backoff** | Low | Medium | Good | Transient failures | | **Circuit breaker** | High | Very High | Good | Preventing cascade failures | | **Caching layer** | Medium | Medium | Excellent | High-volume, repeated queries | --- ## Common Use Cases ### E-Commerce Product Search Agent - Pattern: [RAG for Enterprise](./rag-enterprise) + [Scaling Strategies](./scaling-strategies) - Why: Needs fast search over large catalog with cost control - Key: Hybrid search + caching + batch processing ### Multi-Department Support System - Pattern: [Multi-Agent Orchestration](./multi-agent-orchestration) + [Error Recovery](./error-recovery) - Why: Different agents for different departments with fallback - Key: Agent delegation + circuit breaker pattern ### Financial Analysis Platform - Pattern: [Security Best Practices](./security-best-practices) + [Production Monitoring](./production-monitoring) - Why: Sensitive data + audit requirements + high availability - Key: API key vaults + detailed logging + alerts ### Document Intelligence System - Pattern: [RAG for Enterprise](./rag-enterprise) + [Production Monitoring](./production-monitoring) - Why: Large documents + need quality metrics - Key: Chunking strategy + grounding scores + observability --- ## Quick Links - **Run Samples**: `sbt "samples/runMain org.llm4s.samples.agent.MultiStepAgentExample"` - **View Sample Code**: [modules/samples/src/main/scala/org/llm4s/samples/agent](https://github.com/llm4s/llm4s/tree/main/modules/samples/src/main/scala/org/llm4s/samples/agent) - **Report Issues**: [GitHub Issues](https://github.com/llm4s/llm4s/issues) - **Discuss Patterns**: [GitHub Discussions](https://github.com/llm4s/llm4s/discussions) --- ## Feedback & Contributions These patterns evolve based on community experience. If you've implemented a pattern not covered here, please share it in [Discussions](https://github.com/llm4s/llm4s/discussions) or contribute a guide! --- **Last Updated:** February 2026 **Status:** Stable - Production Ready --- Source: https://llm4s.org/guide/patterns/multi-agent-orchestration # Multi-Agent Orchestration Patterns > **Note:** Code examples in this guide are illustrative pseudocode showing recommended patterns. For working examples using the actual LLM4S API, see [modules/samples](https://github.com/llm4s/llm4s/tree/main/modules/samples). Learn how to design systems where multiple agents collaborate, delegate tasks, and handle complex workflows. This guide covers agent communication patterns, handoff mechanisms, and failure recovery strategies. ## Overview Multi-agent systems allow you to decompose complex tasks into specialized agents. Instead of one agent doing everything, each agent focuses on specific domain expertise: - **Routing Agent**: Routes requests to appropriate specialists - **Domain Specialists**: Domain-specific knowledge (finance, legal, technical) - **Aggregator Agent**: Combines results from multiple agents - **Quality Assurance Agent**: Validates outputs before returning to user --- ## Pattern 1: Sequential Delegation ### Use Case Simple workflows where tasks need to execute in order, each building on previous results. ### Implementation ```text import org.llm4s.agents._ import org.llm4s.Result object SequentialDelegation { // Agent for research val researchAgent = Agent( name = "Research Agent", model = openaiClient, systemPrompt = "You are a research specialist. Find relevant information." ) // Agent for analysis val analysisAgent = Agent( name = "Analysis Agent", model = openaiClient, systemPrompt = "You are an analyst. Analyze provided information critically." ) // Agent for summarization val summaryAgent = Agent( name = "Summary Agent", model = openaiClient, systemPrompt = "You are a technical writer. Create clear, concise summaries." ) // Sequential workflow def processQuery(query: String): Result[String] = for { // Step 1: Research researchResult <- researchAgent.run(query) researchText = researchResult.message // Step 2: Analyze findings analysisResult <- analysisAgent.run( s"Analyze this research: $researchText" ) analysisText = analysisResult.message // Step 3: Summarize summaryResult <- summaryAgent.run( s"Create a summary of this analysis: $analysisText" ) } yield summaryResult.message } ``` ### Advantages - ✅ Simple to understand and implement - ✅ Clear data flow and dependencies - ✅ Easy to debug individual steps ### Disadvantages - ❌ Slower due to sequential execution - ❌ Single point of failure (one agent fails, whole pipeline fails) - ❌ No parallelization opportunities --- ## Pattern 2: Parallel Delegation with Aggregation ### Use Case When multiple independent tasks can run in parallel, then results are combined. The [multi-agent graph recipe](../../examples/cookbook/multi-agent-graph.html) is a worked, tested version of this pattern: two specialist agents run in one superstep of a graph, and an editor agent combines their views behind a static join; cancelling the graph run cancels the agent turns it is waiting on. ### Implementation ```text import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global object ParallelDelegation { val financialAgent = Agent( name = "Financial Agent", model = openaiClient, systemPrompt = "You are a financial analyst." ) val technicalAgent = Agent( name = "Technical Agent", model = openaiClient, systemPrompt = "You are a technical expert." ) val marketAgent = Agent( name = "Market Agent", model = openaiClient, systemPrompt = "You are a market researcher." ) val aggregatorAgent = Agent( name = "Aggregator", model = openaiClient, systemPrompt = "Synthesize viewpoints from multiple experts." ) def analyzeCompany(company: String): Result[String] = { // Run three agents in parallel val financialFuture = Future( financialAgent.run(s"Analyze finances of $company") ) val technicalFuture = Future( technicalAgent.run(s"Analyze tech stack of $company") ) val marketFuture = Future( marketAgent.run(s"Analyze market position of $company") ) // Wait for all results val allResults = for { fin <- financialFuture tech <- technicalFuture market <- marketFuture } yield (fin, tech, market) // Convert Future to Result and aggregate allResults.toResult.flatMap { case (fin, tech, market) => aggregatorAgent.run( s""" |Financial Analysis: ${fin.message} |Technical Analysis: ${tech.message} |Market Analysis: ${market.message} | |Provide a comprehensive assessment combining all three perspectives. """.stripMargin ).map(_.message) } } } ``` ### Advantages - ✅ Parallel execution reduces total time - ✅ Independent agents can scale separately - ✅ Natural load distribution ### Disadvantages - ❌ Increased complexity - ❌ Harder to debug concurrent issues - ❌ Requires aggregation logic --- ## Pattern 3: Handoff Mechanism ### Use Case Transferring control from one agent to another when current agent reaches its limits or recognizes need for specialist. ### Implementation ```text import org.llm4s.agents.handoff._ object HandoffPattern { // Routing agent that delegates to specialists val routingAgent = Agent( name = "Routing Agent", model = openaiClient, systemPrompt = """ You are a routing agent. Analyze the request and determine which specialist agent should handle it: - Technical questions → technical_specialist - Account issues → account_specialist - Billing issues → billing_specialist - Escalated issues → escalation_team """ ) // Specialist agents val technicalSpecialist = Agent( name = "Technical Specialist", model = openaiClient, systemPrompt = "Solve technical problems with deep expertise." ) val accountSpecialist = Agent( name = "Account Specialist", model = openaiClient, systemPrompt = "Help with account management and user issues." ) val billingSpecialist = Agent( name = "Billing Specialist", model = openaiClient, systemPrompt = "Resolve billing and payment issues." ) // Handoff decision sealed trait HandoffTarget case object Technical extends HandoffTarget case object Account extends HandoffTarget case object Billing extends HandoffTarget case object EscalationTeam extends HandoffTarget def routeAndHandle(userRequest: String): Result[String] = for { // Step 1: Route to determine target routing <- routingAgent.run( s"Request: $userRequest\nDetermine target: technical_specialist, account_specialist, billing_specialist, or escalation_team" ) // Step 2: Extract routing decision target = routing.message match { case s if s.contains("technical_specialist") => Technical case s if s.contains("account_specialist") => Account case s if s.contains("billing_specialist") => Billing case _ => EscalationTeam } // Step 3: Hand off to appropriate specialist response <- target match { case Technical => technicalSpecialist.run(userRequest) case Account => accountSpecialist.run(userRequest) case Billing => billingSpecialist.run(userRequest) case EscalationTeam => Result.failure( "This issue requires human escalation. Ticket created." ) } } yield response.message } ``` ### Advantages - ✅ Automatic routing based on request type - ✅ Specialists have focused expertise - ✅ Scales to many agent types - ✅ Built on LLM4S handoff support ### Disadvantages - ❌ Routing errors can send request to wrong agent - ❌ Context loss during handoff - ❌ Overhead of routing step --- ## Pattern 4: Hierarchical Agent Teams ### Use Case Complex systems with multiple management levels (team leads, managers, executives) with clear authority chains. ### Implementation ```text object HierarchicalTeams { // Team leads (specialists) val engineeringLead = Agent(name = "Engineering Lead") val designLead = Agent(name = "Design Lead") val productLead = Agent(name = "Product Lead") // Manager (coordinates across teams) val projectManager = Agent( name = "Project Manager", systemPrompt = """You coordinate across engineering, design, and product teams. You can delegate to leads and synthesize their feedback.""" ) // Executive (makes final decisions) val directorAgent = Agent( name = "Director", systemPrompt = """You review project status and make strategic decisions. You receive summaries from the project manager.""" ) def planNewFeature(featureDescription: String): Result[String] = for { // Level 1: Team leads provide input engineeringPlan <- engineeringLead.run( s"Plan engineering approach for: $featureDescription" ) designPlan <- designLead.run( s"Plan design approach for: $featureDescription" ) productPlan <- productLead.run( s"Plan product approach for: $featureDescription" ) // Level 2: Manager synthesizes feedback managerSummary <- projectManager.run( s"""Coordinate feedback on feature: |Engineering: ${engineeringPlan.message} |Design: ${designPlan.message} |Product: ${productPlan.message} | |Create a comprehensive plan addressing all perspectives.""".stripMargin ) // Level 3: Executive makes decision executiveDecision <- directorAgent.run( s"Review this plan and approve/reject: ${managerSummary.message}" ) } yield executiveDecision.message } ``` ### Advantages - ✅ Clear organization mirroring real teams - ✅ Scalable to deep hierarchies - ✅ Natural delegation and authority - ✅ Easy to understand and maintain ### Disadvantages - ❌ More agents = more cost - ❌ Deeper hierarchies = slower execution - ❌ Potential for bottlenecks at higher levels --- ## Pattern 5: Context Management in Handoffs ### Use Case Ensuring context is preserved and relevant when transferring between agents. ### Implementation ```text import org.llm4s.agents.memory._ object ContextPreservation { case class HandoffContext( conversationHistory: List[String], relevantDocuments: List[String], userProfile: Option[String], constraints: List[String] ) def handoffWithContext( fromAgent: Agent, toAgent: Agent, context: HandoffContext ): Result[String] = { // Build rich context for receiving agent val contextPrompt = s""" |## Conversation Context |${context.conversationHistory.mkString("\n")} | |## Relevant Information |${context.relevantDocuments.mkString("\n")} | |## User Context |${context.userProfile.getOrElse("No profile available")} | |## Constraints |${context.constraints.mkString("\n")} """.stripMargin // Continue conversation with full context toAgent.run(contextPrompt).map(_.message) } // Practical example: Customer support escalation def supportEscalation( userMessage: String, previousInteraction: String ): Result[String] = { val context = HandoffContext( conversationHistory = List(previousInteraction, userMessage), relevantDocuments = List("Knowledge base article on billing"), userProfile = Some("Premium customer, high lifetime value"), constraints = List("No refunds without authorization") ) val level1Agent = Agent(name = "Level 1 Support") val level2Agent = Agent(name = "Level 2 Support") // First attempt level1Agent.run(userMessage).flatMap { result => if (result.message.contains("escalate")) { // Escalate with full context handoffWithContext(level1Agent, level2Agent, context) } else { Result.success(result.message) } } } } ``` ### Advantages - ✅ Prevents context loss during handoffs - ✅ Receiving agent has full information - ✅ Better quality responses in second agent - ✅ Improved user experience ### Disadvantages - ❌ Larger context = higher token cost - ❌ Must carefully select relevant information - ❌ Requires memory/context management system --- ## Failure Handling in Multi-Agent Systems ### Circuit Breaker for Agents ```text object AgentCircuitBreaker { case class CircuitBreakerState( isOpen: Boolean = false, failureCount: Int = 0, successCount: Int = 0, lastFailureTime: Long = 0 ) def runWithCircuitBreaker( agent: Agent, message: String, state: CircuitBreakerState ): Result[String] = { // Check if circuit is open if (state.isOpen) { val timeSinceFailure = System.currentTimeMillis() - state.lastFailureTime if (timeSinceFailure < 60000) { // 1 minute window return Result.failure("Circuit breaker is open, agent temporarily unavailable") } } // Try to execute agent agent.run(message) match { case Result.Success(response) => Result.success(response.message) case Result.Failure(error) => val newFailureCount = state.failureCount + 1 if (newFailureCount >= 5) { // Open after 5 failures Result.failure( s"Agent failed 5 times, circuit breaker opened: $error" ) } else { Result.failure(error) } } } } ``` ### Fallback Agents ```text object FallbackAgents { def runWithFallback( primaryAgent: Agent, fallbackAgent: Agent, message: String ): Result[String] = { primaryAgent.run(message) match { case Result.Success(response) => Result.success(response.message) case Result.Failure(error) => println(s"Primary agent failed: $error. Using fallback...") fallbackAgent.run(message).map(_.message) } } } ``` --- ## Best Practices ### ✅ Do's - **Clear responsibilities**: Each agent should have a well-defined role - **Preserve context**: Pass relevant information during handoffs - **Monitor agent health**: Track success/failure rates - **Implement timeouts**: Prevent agents from hanging indefinitely - **Log all transitions**: Track which agent handled what for auditing - **Test agent chains**: Verify entire workflows work end-to-end - **Handle failures gracefully**: Always have fallback paths ### ❌ Don'ts - **Don't create circular dependencies**: Avoid Agent A calling Agent B calling Agent A - **Don't lose context**: Never handoff without relevant information - **Don't ignore timeouts**: Always set execution time limits - **Don't forget to validate**: Verify outputs before passing between agents - **Don't create too many levels**: Deep hierarchies become hard to manage - **Don't forget cost tracking**: Monitor token usage across agents - **Don't hardcode routing**: Make routing decisions learnable --- ## Performance Characteristics | Pattern | Latency | Cost | Complexity | |---------|---------|------|-----------| | Sequential | High | Low | Low | | Parallel | Low | Medium | Medium | | Handoff | Medium | Medium | Medium | | Hierarchical | Very High | High | High | | Context Preservation | Medium | High | Medium | --- ## Common Pitfalls & Solutions | Pitfall | Problem | Solution | |---------|---------|----------| | Context loss | Agent doesn't know conversation history | Explicit context passing | | Routing errors | Request goes to wrong agent | Validate routing decision + add fallback | | Infinite loops | Agent A → B → A → ... | Graph cycle detection, timeout guards | | Cost explosion | Too many agents or deep chains | Monitor tokens, limit depth | | Slow execution | Sequential chains take too long | Parallelize when possible | --- ## Example: Complete Support System ```text object CompleteSupportSystem { // Routing agent determines issue type val routingAgent = Agent(name = "Support Router") // Specialist agents for different issue types val technicalAgent = Agent(name = "Technical Support") val billingAgent = Agent(name = "Billing Support") val accountAgent = Agent(name = "Account Support") // Escalation agent for complex issues val escalationAgent = Agent(name = "Escalation Team") def handleSupportRequest(userMessage: String): Result[String] = { // Step 1: Route the request routingAgent.run(userMessage).flatMap { routing => val target = routing.message // Step 2: Handle based on routing decision (if (target.contains("technical")) { technicalAgent.run(userMessage) } else if (target.contains("billing")) { billingAgent.run(userMessage) } else if (target.contains("account")) { accountAgent.run(userMessage) } else { escalationAgent.run(userMessage) }).flatMap { response => // Step 3: Check if further escalation needed if (response.message.contains("escalate")) { escalationAgent.run(s"${response.message}\n\nPlease escalate to human team.") } else { Result.success(response.message) } } } } } ``` --- ## See Also - [Error Recovery Patterns](./error-recovery) - Handle agent failures - [Production Monitoring](./production-monitoring) - Monitor multi-agent systems - [Scaling Strategies](./scaling-strategies) - Scale agent deployments - [Agent Framework Documentation](../agents/) --- **Last Updated:** February 2026 **Status:** Production Ready --- Source: https://llm4s.org/guide/patterns/rag-enterprise # RAG for Enterprise > **Note:** Code examples in this guide are illustrative pseudocode showing recommended patterns. For working examples using the actual LLM4S API, see [modules/samples](https://github.com/llm4s/llm4s/tree/main/modules/samples). Learn production-ready strategies for building Retrieval-Augmented Generation (RAG) systems at scale. This guide covers document ingestion, hybrid search, cost optimization, and quality assurance. ## Overview Enterprise RAG requires careful attention to several challenges: - **Document Volume**: Processing thousands or millions of documents - **Freshness**: Keeping retrieved information current - **Quality**: Ensuring search results are relevant and accurate - **Cost**: Minimizing embedding and LLM API costs - **Compliance**: Handling sensitive data and audit trails --- ## Document Ingestion at Scale ### Chunking Strategies #### 1. Fixed-Size Chunks with Overlap ```text object ChunkingStrategies { case class Chunk( text: String, source: String, chunkIndex: Int, metadata: Map[String, String] ) def fixedSizeChunks( text: String, chunkSize: Int = 512, overlapSize: Int = 50, source: String ): List[Chunk] = { val chunks = scala.collection.mutable.ListBuffer[Chunk]() var startIdx = 0 var chunkIndex = 0 while (startIdx < text.length) { val endIdx = Math.min(startIdx + chunkSize, text.length) val chunk = text.substring(startIdx, endIdx) chunks += Chunk( text = chunk, source = source, chunkIndex = chunkIndex, metadata = Map( "startIdx" -> startIdx.toString, "endIdx" -> endIdx.toString ) ) startIdx = endIdx - overlapSize chunkIndex += 1 } chunks.toList } // Example: Fixed 512-token chunks with 50-token overlap val document = "Your long document text here..." val chunks = fixedSizeChunks(document, 512, 50, "documents/whitepaper.pdf") } ``` #### 2. Semantic Chunking ```text object SemanticChunking { case class SemanticChunk( text: String, topic: String, source: String, metadata: Map[String, String] ) def semanticChunks( text: String, source: String, embeddingClient: EmbeddingClient ): Result[List[SemanticChunk]] = { // Split by paragraphs first val paragraphs = text.split("\n\n").toList val chunks = scala.collection.mutable.ListBuffer[SemanticChunk]() var currentChunk = "" var currentTopic = "" paragraphs.foreach { paragraph => currentChunk += "\n" + paragraph // Check if chunk is getting too large if (currentChunk.length > 1000) { // Create chunk chunks += SemanticChunk( text = currentChunk, topic = currentTopic, source = source, metadata = Map("type" -> "semantic") ) currentChunk = "" } } // Add remaining chunk if (currentChunk.nonEmpty) { chunks += SemanticChunk( text = currentChunk, topic = currentTopic, source = source, metadata = Map("type" -> "semantic") ) } Result.success(chunks.toList) } } ``` #### 3. Document-Aware Chunking ```text object DocumentAwareChunking { sealed trait DocumentType case object ResearchPaper extends DocumentType case object LegalDocument extends DocumentType case object ProductSpec extends DocumentType case object WebPage extends DocumentType def chunkByType( text: String, docType: DocumentType, source: String ): List[String] = docType match { // Research papers: chunk by sections case ResearchPaper => text.split("(?=^## |^# )").toList // Legal documents: chunk by clauses case LegalDocument => text.split("(?=\\d+\\.|[A-Z][A-Z ]+\\s*\\n)").toList // Product specs: chunk by features case ProductSpec => text.split("(?=### |^Features|^Specifications)").toList // Web pages: chunk by sections case WebPage => text.split("(?= accFuture.flatMap { processedCount => Future { // Embed batch val embeddings = batch.map { doc => job.embeddingClient.embed(doc) } // Store in vector database embeddings.foreach { embedding => job.vectorStore.store(embedding) } processedCount + batch.length }.flatMap(identity) } } results.map(count => Result.success(count)) } // With progress tracking case class IngestionProgress( processed: Int, total: Int, rate: Double // docs/second ) { def percentage: Double = (processed.toDouble / total) * 100 } def ingestWithProgress( job: IngestionJob, onProgress: IngestionProgress => Unit )(implicit ec: ExecutionContext): Future[Result[Int]] = { val batches = job.documents.grouped(job.batchSize).toList val total = job.documents.length val startTime = System.currentTimeMillis() val results = batches.zipWithIndex.foldLeft(Future.successful(0)) { case (accFuture, (batch, batchIdx)) => accFuture.flatMap { processedCount => Future { // Process batch... val newProcessedCount = processedCount + batch.length // Report progress val elapsed = (System.currentTimeMillis() - startTime) / 1000.0 val rate = newProcessedCount / elapsed onProgress(IngestionProgress(newProcessedCount, total, rate)) newProcessedCount } } } results.map(count => Result.success(count)) } } ``` --- ## Hybrid Search Implementation ### Vector + Keyword Search ```text object HybridSearch { case class SearchResult( content: String, source: String, vectorScore: Double, keywordScore: Double, hybridScore: Double // weighted combination ) def hybridSearch( query: String, vectorStore: VectorStore, keywordIndex: KeywordIndex, alpha: Double = 0.7 // weight for vector score ): Result[List[SearchResult]] = for { // Vector search vectorResults <- vectorStore.search(query, topK = 10) // Keyword search keywordResults <- keywordIndex.search(query, topK = 10) // Normalize scores normalizedVector = vectorResults.map(r => r.copy(score = r.score / vectorResults.map(_.score).max) ) normalizedKeyword = keywordResults.map(r => r.copy(score = r.score / keywordResults.map(_.score).max) ) // Combine results combined = combineResults( normalizedVector, normalizedKeyword, alpha ) } yield combined private def combineResults( vectorResults: List[SearchResult], keywordResults: List[SearchResult], alpha: Double ): List[SearchResult] = { val resultMap = scala.collection.mutable.Map[String, SearchResult]() // Add vector results vectorResults.foreach { r => resultMap(r.source) = r.copy( hybridScore = alpha * r.vectorScore ) } // Add keyword results keywordResults.foreach { r => if (resultMap.contains(r.source)) { val existing = resultMap(r.source) resultMap(r.source) = existing.copy( hybridScore = existing.hybridScore + (1 - alpha) * r.keywordScore ) } else { resultMap(r.source) = r.copy( hybridScore = (1 - alpha) * r.keywordScore ) } } resultMap.values.toList.sortBy(_.hybridScore).reverse } } ``` ### Multi-Stage Retrieval ```text object MultiStageRetrieval { def multiStageRetrieval( query: String, vectorStore: VectorStore, reranker: RerankerModel, grounding: GroundingSystem ): Result[List[RankedResult]] = for { // Stage 1: Broad retrieval candidates <- vectorStore.search(query, topK = 100) // Stage 2: Re-ranking with smaller model reranked <- Future( reranker.rank(query, candidates) ).toResult topK = reranked.take(10) // Stage 3: Grounding check grounded <- Future( topK.filter(r => grounding.isWellGrounded(r.content, query)) ).toResult } yield grounded case class RankedResult( content: String, source: String, score: Double, rerankerScore: Option[Double] = None, isGrounded: Boolean = false ) } ``` --- ## Cost Optimization ### Embedding Caching ```text object EmbeddingCache { import scala.collection.mutable class SmartEmbeddingCache(embeddingClient: EmbeddingClient) { private val cache = mutable.Map[String, Vector[Double]]() private var hitRate = 0.0 private var totalQueries = 0 def embed(text: String): Vector[Double] = { totalQueries += 1 cache.get(text) match { case Some(embedding) => hitRate = (hitRate * (totalQueries - 1) + 1) / totalQueries embedding case None => hitRate = (hitRate * (totalQueries - 1)) / totalQueries val embedding = embeddingClient.embed(text) cache(text) = embedding embedding } } def stats(): CacheStats = CacheStats( hitRate = hitRate, cachedItems = cache.size, estimatedSavings = cache.size * 0.0002 // estimate ~$0.0002 per embedding ) } case class CacheStats( hitRate: Double, cachedItems: Int, estimatedSavings: Double ) } ``` ### Token Cost Tracking ```text object CostTracking { case class CostBreakdown( embeddings: Double, retrievals: Double, llmCalls: Double, total: Double ) case class PerQueryCost( query: String, embeddingCost: Double, retrievalCost: Double, llmCost: Double ) { def total: Double = embeddingCost + retrievalCost + llmCost } class RAGCostTracker { private val queryLogs = scala.collection.mutable.ListBuffer[PerQueryCost]() def logQuery( query: String, embeddingTokens: Int, retrievalTime: Long, llmInputTokens: Int, llmOutputTokens: Int ): Unit = { val embeddingCost = embeddingTokens * 0.00002 // OpenAI pricing val retrievalCost = (retrievalTime / 1000.0) * 0.001 // $0.001 per second val llmCost = (llmInputTokens * 0.000003 + llmOutputTokens * 0.000006) // GPT-4 pricing queryLogs += PerQueryCost( query = query, embeddingCost = embeddingCost, retrievalCost = retrievalCost, llmCost = llmCost ) } def getCostSummary(): CostBreakdown = { val totalEmbeddings = queryLogs.map(_.embeddingCost).sum val totalRetrievals = queryLogs.map(_.retrievalCost).sum val totalLLM = queryLogs.map(_.llmCost).sum CostBreakdown( embeddings = totalEmbeddings, retrievals = totalRetrievals, llmCalls = totalLLM, total = totalEmbeddings + totalRetrievals + totalLLM ) } def averageCostPerQuery(): Double = { if (queryLogs.isEmpty) 0.0 else getCostSummary().total / queryLogs.length } } } ``` ### Query Optimization ```text object QueryOptimization { def optimizeQuery( userQuery: String, optimizationAgent: Agent ): Result[String] = { // Use LLM to reformulate query for better retrieval optimizationAgent.run( s"""Reformulate this search query to maximize relevance in a RAG system: |Original: "$userQuery" | |Requirements: |1. Add semantic keywords |2. Specify document types if relevant |3. Include related concepts |4. Keep under 20 words | |Optimized query:""".stripMargin ).map(_.message) } def multiQueryRetrieval( userQuery: String, optimizationAgent: Agent, vectorStore: VectorStore ): Result[List[SearchResult]] = { // Generate multiple search variants optimizationAgent.run( s"""Generate 3 alternative search queries for: "$userQuery" |Format: Query 1: ..., Query 2: ..., Query 3: ...""".stripMargin ).flatMap { result => val queries = result.message.split(",").map(_.trim).toList // Search with each variant val results = queries.flatMap { q => vectorStore.search(q, topK = 5) } Result.success(results) } } } ``` --- ## Quality Assurance ### Grounding System ```text object Grounding { case class GroundingScore( score: Double, // 0.0 to 1.0 reasoning: String, isHallucination: Boolean ) class GroundingChecker( groundingAgent: Agent, sourceDocuments: List[String] ) { def check( claim: String, retrievedContext: String ): Result[GroundingScore] = { groundingAgent.run( s"""Evaluate if this claim is grounded in the provided context: | |Claim: "$claim" | |Context: |$retrievedContext | |Score (0.0-1.0): [provide number] |Is hallucination: [yes/no] |Reasoning: [brief explanation]""".stripMargin ).map { response => // Parse response to extract score val score = extractScore(response.message) val isHallucination = response.message.contains("yes") GroundingScore( score = score, reasoning = response.message, isHallucination = isHallucination ) } } private def extractScore(response: String): Double = { val pattern = """Score.*?(\d+\.\d+)""".r pattern.findFirstMatchIn(response) .map(_.group(1).toDouble) .getOrElse(0.0) } } } ``` ### Relevance Metrics ```text object RelevanceMetrics { case class RelevanceMetrics( mrr: Double, // Mean Reciprocal Rank ndcg: Double, // Normalized Discounted Cumulative Gain precision: Double, // Precision@10 recall: Double // Recall ) def evaluateRetrieval( query: String, retrievedResults: List[SearchResult], relevantDocuments: List[String] ): RelevanceMetrics = { val mrr = calculateMRR(retrievedResults, relevantDocuments) val ndcg = calculateNDCG(retrievedResults, relevantDocuments) val precision = calculatePrecision(retrievedResults, relevantDocuments) val recall = calculateRecall(retrievedResults, relevantDocuments) RelevanceMetrics( mrr = mrr, ndcg = ndcg, precision = precision, recall = recall ) } private def calculateMRR( retrieved: List[SearchResult], relevant: List[String] ): Double = { retrieved.zipWithIndex.collectFirst { case (result, idx) if relevant.contains(result.source) => 1.0 / (idx + 1) }.getOrElse(0.0) } private def calculateNDCG( retrieved: List[SearchResult], relevant: List[String] ): Double = { val dcg = retrieved.zipWithIndex.map { case (result, idx) => val relevance = if (relevant.contains(result.source)) 1.0 else 0.0 relevance / Math.log(idx + 2) // log base 2 }.sum val idcg = Math.min(relevant.size, retrieved.size) if (idcg == 0) 0.0 else dcg / idcg } private def calculatePrecision( retrieved: List[SearchResult], relevant: List[String] ): Double = { if (retrieved.isEmpty) 0.0 else { val correct = retrieved.count(r => relevant.contains(r.source)) correct.toDouble / Math.min(10, retrieved.size) // Precision@10 } } private def calculateRecall( retrieved: List[SearchResult], relevant: List[String] ): Double = { if (relevant.isEmpty) 0.0 else { val correct = retrieved.count(r => relevant.contains(r.source)) correct.toDouble / relevant.size } } } ``` --- ## Production RAG Pipeline ```text object ProductionRAG { case class RAGConfig( embeddingBatchSize: Int = 50, vectorSearchTopK: Int = 10, hybridAlpha: Double = 0.7, groundingThreshold: Double = 0.7, costTrackingEnabled: Boolean = true ) class ProductionRAGPipeline( config: RAGConfig, embeddingClient: EmbeddingClient, vectorStore: VectorStore, groundingChecker: GroundingChecker, costTracker: CostTracker ) { def answerQuestion( question: String, agent: Agent ): Result[String] = for { // Step 1: Search for relevant documents startTime <- Result.success(System.currentTimeMillis()) searchResults <- vectorStore.search(question, config.vectorSearchTopK) context = searchResults.map(_.content).mkString("\n\n") // Step 2: Generate answer with context result <- agent.run( s"""Answer this question using the provided context. |If the answer is not in the context, say "I don't have enough information". | |Question: $question | |Context: |$context""".stripMargin ) answer <- result.answer.toRight(ValidationError("rag", s"the agent ended with ${result.status}")) // Step 3: Check grounding groundingScore <- groundingChecker.check(answer, context) finalAnswer <- if (groundingScore.score >= config.groundingThreshold) { Result.success(answer) } else { Result.failure( s"Answer not sufficiently grounded (score: ${groundingScore.score}). " + s"Retrieved context may be insufficient." ) } // Step 4: Track costs elapsed <- Result.success(System.currentTimeMillis() - startTime) _ <- if (config.costTrackingEnabled) { costTracker.logQuery( question = question, embeddingTokens = question.split(" ").length * 2, retrievalTime = elapsed, llmInputTokens = (question + context).split(" ").length, llmOutputTokens = finalAnswer.split(" ").length ) Result.success(()) } else { Result.success(()) } } yield finalAnswer } } ``` --- ## Best Practices ### ✅ Do's - **Version your embeddings**: Track which embedding model was used - **Monitor relevance**: Track NDCG and MRR over time - **Batch process documents**: Reduce API costs with batch operations - **Use hybrid search**: Combine vector and keyword for better results - **Track groundedness**: Ensure retrieved context supports answers - **Set TTLs on cache**: Refresh stale embeddings periodically - **A/B test retrieval**: Compare different chunking strategies ### ❌ Don'ts - **Don't ignore chunk quality**: Poor chunks hurt relevance - **Don't forget deduplication**: Avoid indexing same document twice - **Don't hardcode thresholds**: Make grounding scores configurable - **Don't skip validation**: Always verify retrieval quality - **Don't neglect cost tracking**: Monitor and optimize spending - **Don't lose metadata**: Track source, date, version in embeddings - **Don't assume embedding stability**: Embeddings can change between models --- ## See Also - [Production Monitoring](./production-monitoring) - Monitor RAG system health - [Error Recovery](./error-recovery) - Handle retrieval failures - [Scaling Strategies](./scaling-strategies) - Scale RAG to large datasets --- **Last Updated:** February 2026 **Status:** Production Ready --- Source: https://llm4s.org/guide/patterns/production-monitoring # Production Monitoring > **Note:** Code examples in this guide are illustrative pseudocode showing recommended patterns. For working examples using the actual LLM4S API, see [modules/samples](https://github.com/llm4s/llm4s/tree/main/modules/samples). Comprehensive strategies for observing, debugging, and optimizing LLM applications in production. This guide covers metrics collection, alerting, cost tracking, and debugging techniques. ## Overview Production monitoring for LLM applications requires tracking multiple dimensions: - **Performance**: Latency, throughput, error rates - **Quality**: Output quality, grounding scores, user satisfaction - **Cost**: Token usage, API costs, ROI by feature - **Reliability**: Uptime, error recovery, circuit breaker status --- ## Key Metrics to Track ### Performance Metrics ```text object PerformanceMetrics { case class PerformanceSnapshot( p50Latency: Long, // milliseconds p95Latency: Long, p99Latency: Long, avgLatency: Double, throughput: Double, // requests/second errorRate: Double // percentage ) class LatencyTracker { private val latencies = scala.collection.mutable.ListBuffer[Long]() def recordLatency(ms: Long): Unit = { latencies += ms } def snapshot(): PerformanceSnapshot = { val sorted = latencies.sorted val p50 = sorted((sorted.length * 0.5).toInt) val p95 = sorted((sorted.length * 0.95).toInt) val p99 = sorted((sorted.length * 0.99).toInt) val avg = sorted.sum.toDouble / sorted.length PerformanceSnapshot( p50Latency = p50, p95Latency = p95, p99Latency = p99, avgLatency = avg, throughput = calculateThroughput(), errorRate = calculateErrorRate() ) } private def calculateThroughput(): Double = { // requests per second latencies.length.toDouble / (latencies.sum.toDouble / 1000.0) } private def calculateErrorRate(): Double = { // Calculate percentage of requests that failed 0.0 // implementation } } } ``` ### Quality Metrics ```text object QualityMetrics { case class QualityScore( groundingScore: Double, // 0.0-1.0 relevanceScore: Double, // 0.0-1.0 userSatisfaction: Double, // 1-5 stars hallucination: Boolean, timeToFirstToken: Long // ms ) { def overallQuality: Double = (groundingScore + relevanceScore + userSatisfaction / 5.0) / 3.0 } class QualityMonitor(groundingChecker: GroundingChecker) { private val scores = scala.collection.mutable.ListBuffer[QualityScore]() def recordScore(score: QualityScore): Unit = { scores += score } def getAverageQuality(): Double = { if (scores.isEmpty) 0.0 else scores.map(_.overallQuality).sum / scores.length } def getQualityByModel(model: String): Double = { // Track quality per model to detect regressions 0.0 } } } ``` ### Cost Metrics ```text object CostMetrics { case class CostSnapshot( totalTokensUsed: Long, totalCost: Double, costPerRequest: Double, costByProvider: Map[String, Double], costTrend: List[Double] // last 7 days ) class CostMonitor { private val dailyCosts = scala.collection.mutable.Map[String, Double]() private val providerCosts = scala.collection.mutable.Map[String, Double]() def recordAPICall( provider: String, inputTokens: Int, outputTokens: Int, cost: Double ): Unit = { val today = java.time.LocalDate.now().toString dailyCosts(today) = dailyCosts.getOrElse(today, 0.0) + cost providerCosts(provider) = providerCosts.getOrElse(provider, 0.0) + cost } def getDailyBudgetStatus(dailyBudget: Double): String = { val today = java.time.LocalDate.now().toString val spent = dailyCosts.getOrElse(today, 0.0) val percentUsed = (spent / dailyBudget) * 100 s"Daily budget: $$${spent}/ $$${dailyBudget} (${percentUsed.toInt}%)" } def predictMonthlySpend(): Double = { val currentDay = java.time.LocalDate.now().getDayOfMonth val spentToDate = dailyCosts.values.sum (spentToDate / currentDay) * 30 } } } ``` --- ## Alerting Strategy ### Alert Rules ```text object AlertRules { sealed trait AlertSeverity case object Critical extends AlertSeverity case object Warning extends AlertSeverity case object Info extends AlertSeverity case class Alert( severity: AlertSeverity, title: String, message: String, affectedComponent: String, timestamp: Long = System.currentTimeMillis() ) class AlertManager { private val alertHandlers = scala.collection.mutable.ListBuffer[ Alert => Unit ]() def registerHandler(handler: Alert => Unit): Unit = { alertHandlers += handler } def raiseAlert(alert: Alert): Unit = { alertHandlers.foreach(_(alert)) } } // Alert rules def setupAlerts(alertManager: AlertManager): Unit = { // High error rate if (errorRate > 0.05) { // > 5% alertManager.raiseAlert(Alert( severity = Critical, title = "High error rate detected", message = s"Error rate is ${(errorRate * 100).toInt}%, threshold is 5%", affectedComponent = "LLM API" )) } // High latency if (p99Latency > 30000) { // > 30 seconds alertManager.raiseAlert(Alert( severity = Warning, title = "High p99 latency", message = s"P99 latency is ${p99Latency}ms", affectedComponent = "API Gateway" )) } // Daily spend exceeded if (dailySpend > dailyBudget * 1.1) { // > 110% of budget alertManager.raiseAlert(Alert( severity = Warning, title = "Daily budget exceeded", message = s"Spent $$${dailySpend}, budget is $$${dailyBudget}", affectedComponent = "Cost Tracking" )) } // Low grounding score if (averageGroundingScore < 0.7) { alertManager.raiseAlert(Alert( severity = Warning, title = "Low grounding score", message = s"Average grounding score is ${averageGroundingScore}, target is 0.8+", affectedComponent = "RAG Quality" )) } } } ``` --- ## Debugging in Production ### Request Tracing ```text object RequestTracing { case class TraceSpan( spanId: String, traceId: String, operationName: String, startTime: Long, endTime: Long, attributes: Map[String, String], events: List[TraceEvent], status: String ) { def duration: Long = endTime - startTime } case class TraceEvent( timestamp: Long, name: String, attributes: Map[String, String] ) class RequestTracer { private val traces = scala.collection.mutable.Map[String, TraceSpan]() def startSpan( traceId: String, operationName: String ): String = { val spanId = java.util.UUID.randomUUID().toString traces(spanId) = TraceSpan( spanId = spanId, traceId = traceId, operationName = operationName, startTime = System.currentTimeMillis(), endTime = 0, attributes = Map(), events = List(), status = "UNSET" ) spanId } def addEvent( spanId: String, eventName: String, attributes: Map[String, String] ): Unit = { traces.get(spanId).foreach { span => val event = TraceEvent(System.currentTimeMillis(), eventName, attributes) traces(spanId) = span.copy( events = span.events :+ event ) } } def endSpan( spanId: String, status: String = "OK" ): Unit = { traces.get(spanId).foreach { span => traces(spanId) = span.copy( endTime = System.currentTimeMillis(), status = status ) } } def getTrace(traceId: String): List[TraceSpan] = { traces.values.filter(_.traceId == traceId).toList } } } ``` ### Debug Logging ```text object DebugLogging { import org.slf4j.LoggerFactory val logger = LoggerFactory.getLogger("LLM4S") def debugRAGQuery( query: String, results: List[SearchResult], retrievalTime: Long ): Unit = { logger.debug(s"RAG Query: $query") logger.debug(s"Retrieved ${results.length} documents in ${retrievalTime}ms") results.take(3).foreach { result => logger.debug(s" - ${result.source} (score: ${result.score})") } } def debugAgentExecution( agentName: String, input: String, output: String, tokens: (Int, Int), // (input, output) latency: Long ): Unit = { logger.debug(s"Agent: $agentName") logger.debug(s"Input tokens: ${tokens._1}, Output tokens: ${tokens._2}") logger.debug(s"Latency: ${latency}ms") logger.debug(s"Output length: ${output.length} chars") } } ``` --- ## Cost Tracking in Detail ### Per-Feature Cost Breakdown ```text object FeatureCostTracking { case class FeatureCost( featureName: String, usage: Long, // number of times used totalCost: Double, costPerUse: Double, roi: Double // revenue generated / cost ) class FeatureCostMonitor { private val features = scala.collection.mutable.Map[String, FeatureCost]() def recordFeatureUsage( featureName: String, tokensUsed: Int, costIncurred: Double, revenueGenerated: Double = 0.0 ): Unit = { features.get(featureName) match { case Some(existing) => features(featureName) = existing.copy( usage = existing.usage + 1, totalCost = existing.totalCost + costIncurred, costPerUse = (existing.totalCost + costIncurred) / (existing.usage + 1), roi = revenueGenerated / costIncurred ) case None => features(featureName) = FeatureCost( featureName = featureName, usage = 1, totalCost = costIncurred, costPerUse = costIncurred, roi = revenueGenerated / costIncurred ) } } def getLeastProfitableFeatures(topN: Int = 5): List[FeatureCost] = { features.values.toList .sortBy(_.roi) .take(topN) } def getMostCostlyFeatures(topN: Int = 5): List[FeatureCost] = { features.values.toList .sortBy(_.totalCost) .reverse .take(topN) } } } ``` --- ## Production Dashboard Queries ### BigQuery Examples ```sql -- Daily API costs by provider SELECT DATE(timestamp) as date, provider, COUNT(*) as requests, SUM(input_tokens + output_tokens) as total_tokens, SUM(cost) as daily_cost FROM llm_calls GROUP BY date, provider ORDER BY date DESC, daily_cost DESC; -- Latency percentiles SELECT PERCENTILE_CONT(latency_ms, 0.50) as p50, PERCENTILE_CONT(latency_ms, 0.95) as p95, PERCENTILE_CONT(latency_ms, 0.99) as p99, AVG(latency_ms) as avg FROM llm_calls WHERE timestamp > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR); -- Agent success rates SELECT agent_name, COUNT(*) as executions, COUNTIF(status = 'SUCCESS') as successes, ROUND(COUNTIF(status = 'SUCCESS') / COUNT(*) * 100, 2) as success_rate FROM agent_executions GROUP BY agent_name; -- Grounding score distribution SELECT grounding_score, COUNT(*) as count, ROUND(COUNT(*) / SUM(COUNT(*)) OVER () * 100, 2) as percentage FROM rag_outputs GROUP BY grounding_score ORDER BY grounding_score DESC; ``` --- ## Best Practices ### ✅ Do's - **Track at request level**: Include latency, tokens, cost in every call - **Set alert thresholds**: Define what "bad" looks like - **Monitor trends**: Track metrics over days/weeks, not just seconds - **Tag your spans**: Add metadata for easy filtering - **Sample logs intelligently**: Don't log every request, but log errors - **Archive metrics**: Keep historical data for trend analysis - **Test your alerts**: Verify alerts actually fire - **Correlate metrics**: Link errors to cost spikes, latency to quality ### ❌ Don'ts - **Don't ignore errors**: Every error is a signal - **Don't forget context**: Without tags, logs are useless - **Don't alert on everything**: Alert fatigue leads to ignored alerts - **Don't assume correlation**: High cost doesn't always mean issues - **Don't hardcode thresholds**: Make them configurable - **Don't lose audit trails**: Keep complete history for compliance - **Don't mix concerns**: Separate performance from cost from quality metrics --- ## See Also - [Cost Tracking & Optimization](../issues/#11) - Detailed cost analysis - [Error Recovery](./error-recovery) - Handle failures - [Security Best Practices](./security-best-practices) - Audit logging --- **Last Updated:** February 2026 **Status:** Production Ready --- Source: https://llm4s.org/guide/patterns/scaling-strategies # Scaling Strategies > **Note:** Code examples in this guide are illustrative pseudocode showing recommended patterns. For working examples using the actual LLM4S API, see [modules/samples](https://github.com/llm4s/llm4s/tree/main/modules/samples). Production patterns for handling high throughput, reducing latency, and distributing load. This guide covers caching, rate limiting, batch processing, and distributed execution. ## Caching Strategies ### Request-Level Caching ```text object RequestCaching { import scala.collection.mutable import scala.concurrent.duration._ case class CachedResponse( result: String, timestamp: Long, ttl: Duration ) { def isExpired: Boolean = System.currentTimeMillis() - timestamp > ttl.toMillis } class RequestCache(maxSize: Int = 1000) { private val cache = mutable.LinkedHashMap[String, CachedResponse]() def get(key: String): Option[String] = { cache.get(key).flatMap { cached => if (cached.isExpired) { cache.remove(key) None } else { Some(cached.result) } } } def put(key: String, value: String, ttl: Duration): Unit = { if (cache.size >= maxSize) { cache.remove(cache.head._1) // Remove oldest } cache(key) = CachedResponse(value, System.currentTimeMillis(), ttl) } def stats(): CacheStats = CacheStats( size = cache.size, hits = hitCount, misses = missCount, hitRate = hitCount.toDouble / (hitCount + missCount) ) } case class CacheStats(size: Int, hits: Long, misses: Long, hitRate: Double) } ``` ### Distributed Cache (Redis) ```text object DistributedCache { import redis.clients.jedis.Jedis class RedisCache(host: String = "localhost", port: Int = 6379) { private val jedis = new Jedis(host, port) def get(key: String): Option[String] = { Option(jedis.get(key)) } def put(key: String, value: String, ttlSeconds: Int): Unit = { jedis.setex(key, ttlSeconds, value) } def invalidate(keyPattern: String): Long = { val keys = jedis.keys(keyPattern) if (keys.isEmpty) 0L else jedis.del(keys.toArray: _*) } } // Usage with LLM4S def answerWithCaching( query: String, agent: Agent, cache: RedisCache ): Result[String] = { val cacheKey = s"query:${query.hashCode}" cache.get(cacheKey) match { case Some(cachedAnswer) => println(s"Cache hit for: $query") Result.success(cachedAnswer) case None => agent.run(query).map { response => cache.put(cacheKey, response.message, ttlSeconds = 3600) response.message } } } } ``` --- ## Rate Limiting ### Token Bucket Algorithm ```text object RateLimiting { import scala.concurrent.duration._ class TokenBucket( capacity: Int, refillRate: Int, // tokens per second refillInterval: Duration = 1.second ) { private var tokens = capacity.toDouble private var lastRefillTime = System.currentTimeMillis() def tryConsume(amount: Int = 1): Boolean = { refill() if (tokens >= amount) { tokens -= amount true } else { false } } def availableTokens: Int = tokens.toInt private def refill(): Unit = { val now = System.currentTimeMillis() val timeSinceLastRefill = now - lastRefillTime val tokensToAdd = (timeSinceLastRefill / refillInterval.toMillis) * refillRate tokens = Math.min(capacity, tokens + tokensToAdd) lastRefillTime = now } } // Usage val rateLimiter = new TokenBucket( capacity = 1000, refillRate = 100 // 100 tokens per second = 100 requests/sec ) def limitedLLMCall( query: String, agent: Agent ): Result[String] = { if (rateLimiter.tryConsume()) { agent.run(query) } else { Result.failure(s"Rate limit exceeded. Available tokens: ${rateLimiter.availableTokens}") } } } ``` ### Per-User Rate Limiting ```text object PerUserRateLimiting { import scala.collection.mutable class UserRateLimiter { private val buckets = mutable.Map[String, TokenBucket]() def getLimiter(userId: String): TokenBucket = { buckets.getOrElseUpdate(userId, new TokenBucket(100, 10)) } def tryConsume(userId: String): Boolean = { getLimiter(userId).tryConsume() } def getStatus(userId: String): String = { val limiter = getLimiter(userId) s"User $userId has ${limiter.availableTokens} tokens available" } } } ``` --- ## Batch Processing ### Embedding Batching ```text object EmbeddingBatching { import scala.concurrent.Future case class EmbeddingJob( text: String, callback: Vector[Double] => Unit ) class BatchEmbeddingProcessor( embeddingClient: EmbeddingClient, batchSize: Int = 50, batchTimeoutMs: Int = 1000 ) { private var batch = scala.collection.mutable.ListBuffer[EmbeddingJob]() private var lastBatchTime = System.currentTimeMillis() def embed(text: String): Future[Vector[Double]] = { val promise = scala.concurrent.Promise[Vector[Double]]() synchronized { batch += EmbeddingJob(text, embedding => { promise.success(embedding) }) if (batch.size >= batchSize || System.currentTimeMillis() - lastBatchTime > batchTimeoutMs) { processBatch() } } promise.future } private def processBatch(): Unit = { if (batch.nonEmpty) { val texts = batch.map(_.text).toList val callbacks = batch.map(_.callback).toList // Embed all at once val embeddings = embeddingClient.embedBatch(texts) // Call callbacks embeddings.zip(callbacks).foreach { case (emb, callback) => callback(emb) } batch.clear() lastBatchTime = System.currentTimeMillis() } } } } ``` --- ## Distributed Execution ### Worker Pool Pattern ```text object WorkerPool { import scala.concurrent.{ExecutionContext, Future} import java.util.concurrent.{Executors, ThreadPoolExecutor} class AgentWorkerPool(numWorkers: Int) { private implicit val ec = ExecutionContext.fromExecutor( Executors.newFixedThreadPool(numWorkers) ) def processInParallel[T]( tasks: List[String], processor: String => Result[T] ): Future[List[Result[T]]] = { val futures = tasks.map(task => Future { processor(task) } ) Future.sequence(futures) } } // Usage: Process multiple queries in parallel def processMultipleQueries( queries: List[String], agent: Agent ): Future[List[Result[String]]] = { val pool = new AgentWorkerPool(numWorkers = 4) pool.processInParallel(queries) { query => agent.run(query).map(_.message) } } } ``` ### Load Balancing Across Models ```text object LoadBalancing { case class ModelLoad( modelName: String, currentLoad: Int, capacity: Int ) { def utilization: Double = currentLoad.toDouble / capacity } class LoadBalancer(models: List[LlmClient]) { private val loads = scala.collection.mutable.Map[String, Int]() models.foreach(m => loads(m.modelName) = 0) def selectLeastLoaded(): LlmClient = { models.minBy(m => loads(m.modelName)) } def recordRequest(modelName: String): Unit = { loads(modelName) = loads.getOrElse(modelName, 0) + 1 } def recordCompletion(modelName: String): Unit = { loads(modelName) = Math.max(0, loads.getOrElse(modelName, 1) - 1) } def execute(query: String): Result[String] = { val model = selectLeastLoaded() recordRequest(model.modelName) try { model.generate(query) } finally { recordCompletion(model.modelName) } } } } ``` --- ## Queue-Based Processing ### Task Queue ```text object TaskQueue { import scala.concurrent.{Future, Promise} import scala.collection.mutable sealed trait TaskStatus case object Pending extends TaskStatus case object Processing extends TaskStatus case object Completed extends TaskStatus case object Failed extends TaskStatus case class Task( id: String, query: String, status: TaskStatus = Pending, result: Option[String] = None ) class TaskProcessor(agent: Agent, numWorkers: Int = 3) { private val queue = mutable.Queue[Task]() private val tasks = mutable.Map[String, Task]() private var running = true def submitTask(query: String): String = { val taskId = java.util.UUID.randomUUID().toString val task = Task(taskId, query) queue.enqueue(task) tasks(taskId) = task taskId } def getStatus(taskId: String): Option[TaskStatus] = { tasks.get(taskId).map(_.status) } def getResult(taskId: String): Option[String] = { tasks.get(taskId).flatMap(_.result) } def start(): Future[Unit] = { Future { while (running) { if (queue.nonEmpty) { val task = queue.dequeue() processTask(task) } Thread.sleep(100) } }(scala.concurrent.ExecutionContext.global) } private def processTask(task: Task): Unit = { tasks(task.id) = task.copy(status = Processing) agent.run(task.query) match { case Result.Success(response) => tasks(task.id) = task.copy( status = Completed, result = Some(response.message) ) case Result.Failure(error) => tasks(task.id) = task.copy( status = Failed, result = Some(s"Error: $error") ) } } } } ``` --- ## Performance Optimization ### Inference Optimization ```text object InferenceOptimization { // Use smaller models for simple tasks def selectModelByComplexity(query: String): LlmClient = { if (query.length < 100) { fastModel // faster, cheaper } else { powerfulModel // slower, more capable } } // Parallel generation with early stopping def generateWithTimeout( agent: Agent, query: String, timeoutMs: Long = 5000 ): Result[String] = { val future = scala.concurrent.Future { agent.run(query) }(scala.concurrent.ExecutionContext.global) scala.concurrent.Await.result( future, scala.concurrent.duration.Duration(timeoutMs, "ms") ) } // Speculative decoding (decode multiple possibilities in parallel) def speculativeDecoding( agent: Agent, query: String ): Result[String] = { // Generate with draft model val draft = draftAgent.run(query) // Verify with main model val verified = agent.run(draft.message) verified } } ``` --- ## Scaling Checklist ### ✅ Before scaling - [ ] Current implementation handles 10x load? - [ ] Identified bottleneck (CPU, memory, I/O)? - [ ] Have baseline metrics? - [ ] Cost estimates for 10x scale? - [ ] Cache strategy defined? - [ ] Rate limiting in place? - [ ] Error handling for overload? ### ✅ Scaling implementation - [ ] Horizontal scaling set up? - [ ] Load balancing configured? - [ ] Database/cache scaled? - [ ] Monitoring alarms set? - [ ] Gradual rollout planned (10% → 25% → 50% → 100%)? - [ ] Rollback plan ready? - [ ] Performance verified at scale? --- ## See Also - [Production Monitoring](./production-monitoring) - Monitor scaled systems - [Error Recovery](./error-recovery) - Handle failures at scale - [Security Best Practices](./security-best-practices) - Secure at scale --- **Last Updated:** February 2026 **Status:** Production Ready --- Source: https://llm4s.org/guide/patterns/error-recovery # Error Recovery Patterns > **Note:** Code examples in this guide are illustrative pseudocode showing recommended patterns. For working examples using the actual LLM4S API, see [modules/samples](https://github.com/llm4s/llm4s/tree/main/modules/samples). Resilience strategies for handling failures, recovering from errors, and gracefully degrading functionality. This guide covers retries, circuit breakers, fallbacks, and failure scenarios. ## Retry Strategies ### Exponential Backoff with Jitter ```text object RetryStrategies { import scala.concurrent.{Future, ExecutionContext} import scala.concurrent.duration._ import scala.util.{Try, Success, Failure} case class RetryConfig( maxAttempts: Int = 5, initialDelayMs: Long = 100, maxDelayMs: Long = 32000, multiplier: Double = 2.0, jitterFactor: Double = 0.1 ) def exponentialBackoffRetry[T]( config: RetryConfig, operation: () => Result[T] )(implicit ec: ExecutionContext): Future[Result[T]] = { def retry(attempt: Int): Future[Result[T]] = { operation() match { case success @ Result.Success(_) => Future.successful(success) case failure @ Result.Failure(_) if attempt >= config.maxAttempts => Future.successful(failure) case failure @ Result.Failure(error) => // Calculate delay with exponential backoff and jitter val exponentialDelay = Math.min( config.maxDelayMs, config.initialDelayMs * Math.pow(config.multiplier, attempt - 1) ).toLong val jitter = (Math.random() - 0.5) * 2 * exponentialDelay * config.jitterFactor val totalDelay = Math.max(0, exponentialDelay + jitter.toLong) println(s"Attempt $attempt failed: $error. Retrying in ${totalDelay}ms...") // Wait and retry akka.pattern.after(totalDelay.millis, scala.concurrent.ExecutionContext.Implicits.global )(retry(attempt + 1)) } } retry(1) } } ``` ### Retry with Timeout ```text object TimeoutRetry { def retryWithTimeout[T]( operation: () => Result[T], timeoutMs: Long = 30000, maxRetries: Int = 3 ): Result[T] = { var lastError: String = "" for (attempt <- 1 to maxRetries) { try { val future = scala.concurrent.Future { operation() }(scala.concurrent.ExecutionContext.global) val result = scala.concurrent.Await.result( future, scala.concurrent.duration.Duration(timeoutMs, "ms") ) return result } catch { case e: scala.concurrent.TimeoutException => lastError = s"Timeout after ${timeoutMs}ms" if (attempt < maxRetries) { Thread.sleep(1000L * attempt) // Increasing delay } case e: Exception => lastError = e.getMessage if (attempt < maxRetries) { Thread.sleep(1000L * attempt) } } } Result.failure(s"Failed after $maxRetries attempts: $lastError") } } ``` --- ## Circuit Breaker Pattern ### Implementation ```text object CircuitBreaker { import scala.concurrent.duration._ sealed trait CircuitState case object Closed extends CircuitState // Operating normally case object Open extends CircuitState // Failing, reject requests case object HalfOpen extends CircuitState // Testing if recovered case class CircuitBreakerConfig( failureThreshold: Int = 5, // failures before opening successThreshold: Int = 2, // successes in half-open before closing timeout: Duration = 60.seconds // time before trying half-open ) class CircuitBreakerImpl[T](config: CircuitBreakerConfig) { private var state: CircuitState = Closed private var failureCount = 0 private var successCount = 0 private var lastFailureTime = System.currentTimeMillis() def execute[A](operation: () => Result[A]): Result[A] = { state match { case Closed => executeInClosedState(operation) case Open => executeInOpenState(operation) case HalfOpen => executeInHalfOpenState(operation) } } private def executeInClosedState[A]( operation: () => Result[A] ): Result[A] = { operation() match { case success @ Result.Success(_) => failureCount = 0 success case failure @ Result.Failure(error) => failureCount += 1 if (failureCount >= config.failureThreshold) { state = Open println(s"Circuit breaker opened after $failureCount failures") } failure } } private def executeInOpenState[A]( operation: () => Result[A] ): Result[A] = { val timeSinceFailure = System.currentTimeMillis() - lastFailureTime if (timeSinceFailure > config.timeout.toMillis) { state = HalfOpen successCount = 0 println("Circuit breaker half-open, testing recovery...") executeInHalfOpenState(operation) } else { Result.failure( s"Circuit breaker is OPEN. Service unavailable. Retry in " + s"${config.timeout.toMillis - timeSinceFailure}ms" ) } } private def executeInHalfOpenState[A]( operation: () => Result[A] ): Result[A] = { operation() match { case success @ Result.Success(_) => successCount += 1 if (successCount >= config.successThreshold) { state = Closed failureCount = 0 println("Circuit breaker closed, service recovered") } success case failure @ Result.Failure(error) => state = Open lastFailureTime = System.currentTimeMillis() println("Circuit breaker reopened") failure } } } } ``` ### Usage with Agents ```text object CircuitBreakerAgent { def safeAgentCall( agent: Agent, query: String, breaker: CircuitBreaker ): Result[String] = { breaker.execute { () => agent.run(query).map(_.message) } } } ``` --- ## Fallback Strategies ### Model Fallback ```text object ModelFallback { case class ModelFallbackConfig( primaryModel: LlmClient, secondaryModel: LlmClient, tertiaryModel: LlmClient ) def runWithFallback( query: String, config: ModelFallbackConfig ): Result[String] = { println("Attempting primary model...") config.primaryModel.generate(query) match { case success @ Result.Success(_) => success case Result.Failure(error1) => println(s"Primary failed: $error1. Trying secondary...") config.secondaryModel.generate(query) match { case success @ Result.Success(_) => success case Result.Failure(error2) => println(s"Secondary failed: $error2. Trying tertiary...") config.tertiaryModel.generate(query) match { case success @ Result.Success(_) => success case Result.Failure(error3) => Result.failure( s"All models failed. Primary: $error1, Secondary: $error2, Tertiary: $error3" ) } } } } // Cost-aware fallback: use cheaper models first def costAwareFallback( query: String, models: List[(LlmClient, Double)] // (model, costPer1kTokens) ): Result[String] = { val sortedByPrice = models.sortBy(_._2) // Cheapest first sortedByPrice.foldLeft[Result[String]]( Result.failure("No models available") ) { case (acc, (model, price)) => acc match { case success @ Result.Success(_) => success case Result.Failure(_) => println(s"Trying ${model.modelName} (price: $$${price}/1k tokens)") model.generate(query) } } } } ``` ### Cached Fallback ```text object CachedFallback { def runWithCacheFallback( query: String, agent: Agent, cache: Cache[String], circuitBreaker: CircuitBreaker ): Result[String] = { // Try circuit breaker with cache fallback circuitBreaker.execute { () => agent.run(query) } match { case success @ Result.Success(_) => // Cache the successful response cache.put(query, success.message, ttl = 3600) success case Result.Failure(error) => // Fall back to cache cache.get(query) match { case Some(cachedResponse) => println(s"Service failed, using cached response") Result.success(cachedResponse) case None => println(s"Service failed and no cache available") Result.failure(error) } } } } ``` --- ## Graceful Degradation ### Feature Degradation ```text object GracefulDegradation { case class RAGResult( answer: String, sources: List[String], groundingScore: Double, degradedMode: Boolean = false ) def answerWithDegradation( query: String, ragSystem: RAGSystem, agent: Agent ): RAGResult = { // Try full RAG pipeline try { val sources = ragSystem.retrieve(query) val context = sources.map(_.content).mkString("\n") val answer = agent .run(s"Answer this question using the context:\n$context\n\nQuestion: $query") .toOption .flatMap(_.answer) // Some only for a Completed turn .getOrElse("") val groundingScore = ragSystem.checkGrounding(answer, sources) RAGResult( answer = answer, sources = sources.map(_.url), groundingScore = groundingScore, degradedMode = false ) } catch { case e: Exception => println(s"RAG pipeline failed: ${e.getMessage}. Using degraded mode.") // Fall back to simple generation without RAG val answer = agent.run(query).toOption.flatMap(_.answer).getOrElse("") RAGResult( answer = answer, sources = List(), // No sources groundingScore = 0.0, // Unknown grounding degradedMode = true ) } } // Progressive degradation - disable expensive features def progressiveDegradation( operationCost: Double, costBudgetRemaining: Double ): List[String] = { val disabledFeatures = scala.collection.mutable.ListBuffer[String]() if (operationCost > costBudgetRemaining * 0.5) { disabledFeatures += "grounding_check" // Disable grounding check } if (operationCost > costBudgetRemaining * 0.7) { disabledFeatures += "reranking" // Disable reranking } if (operationCost > costBudgetRemaining * 0.9) { disabledFeatures += "multi_stage_retrieval" // Use single-stage } disabledFeatures.toList } } ``` --- ## Failure Scenarios & Responses ### Timeout Handling ```text object TimeoutHandling { def handleTimeout[T]( operation: String, timeoutMs: Long ): Result[String] = { Result.failure( s"$operation timed out after ${timeoutMs}ms. " + s"This may indicate the service is overloaded or network is slow. " + s"Please try again in a moment." ) } } ``` ### Rate Limit Handling ```text object RateLimitHandling { def handleRateLimit( retryAfterSeconds: Int ): Result[String] = { Result.failure( s"Rate limit exceeded. Please retry after $retryAfterSeconds seconds. " + s"Consider implementing exponential backoff and jitter in your client." ) } } ``` ### Provider Outage Handling ```text object OutageHandling { def handleProviderOutage( provider: String, fallbackAvailable: Boolean ): Result[String] = { if (fallbackAvailable) { Result.failure( s"$provider is temporarily unavailable. Using fallback provider." ) } else { Result.failure( s"$provider is temporarily unavailable and no fallback is configured. " + s"Please try again later." ) } } } ``` --- ## Best Practices ### ✅ Do's - **Retry transient errors only**: Don't retry 401 authentication errors - **Use exponential backoff**: Prevents overwhelming failed services - **Add jitter**: Prevents thundering herd problems - **Circuit break early**: Don't wait for timeouts - **Log all retries**: For debugging and monitoring - **Have multiple fallbacks**: Don't rely on single fallback - **Test failure paths**: Ensure graceful degradation works - **Set sensible timeouts**: Not too short (false failures), not too long (user wait) ### ❌ Don'ts - **Don't retry forever**: Set max attempt limit - **Don't hardcode delays**: Make retry config configurable - **Don't lose context**: Include error details in logs - **Don't assume fixed backoff works**: Different errors need different strategies - **Don't ignore context limits**: LLM calls may fail near context window limit - **Don't forget to reset state**: Circuit breaker needs periodic recovery attempts - **Don't cascade failures**: Use circuit breakers to isolate failures --- ## Error Recovery Checklist ### ✅ Implementation checklist - [ ] All API calls wrapped with retry logic? - [ ] Circuit breaker implemented for external services? - [ ] Fallback models/providers defined? - [ ] Timeout configured appropriately? - [ ] Error messages are user-friendly? - [ ] Errors are logged with full context? - [ ] Monitoring alerts for error rates? - [ ] Graceful degradation tested? - [ ] Failure scenarios documented? --- ## See Also - [Production Monitoring](./production-monitoring) - Monitor error rates - [Scaling Strategies](./scaling-strategies) - Handle failures under load - [Multi-Agent Orchestration](./multi-agent-orchestration) - Fallback agents --- **Last Updated:** February 2026 **Status:** Production Ready --- Source: https://llm4s.org/guide/patterns/security-best-practices # Security Best Practices > **Note:** Code examples in this guide are illustrative pseudocode showing recommended patterns. For working examples using the actual LLM4S API, see [modules/samples](https://github.com/llm4s/llm4s/tree/main/modules/samples). Comprehensive security patterns for protecting sensitive data, managing credentials, and maintaining audit trails in production LLM systems. ## API Key Management > **Note:** Code examples in this guide are illustrative pseudocode showing recommended patterns. For working examples using the actual LLM4S API, see [modules/samples](https://github.com/llm4s/llm4s/tree/main/modules/samples). ### Configuration-Based Access ```text object SecureKeyManagement { import org.llm4s.config.Llm4sConfig def loadApiKey(provider: String): Result[String] = { Llm4sConfig.provider(provider).apiKey match { case Some(key) if key.nonEmpty => Result.success(key) case _ => Result.failure( s"API key for provider '$provider' not configured. " + s"Set it in your configuration file or environment." ) } } // Safe logging (don't log keys!) def logApiUsage(key: String, provider: String): Unit = { val maskedKey = key.take(5) + "..." + key.takeRight(5) println(s"Using provider: $provider with key: $maskedKey") } } ``` ### Key Vault Integration ```text object KeyVault { import com.azure.security.keyvault.secrets._ class SecureKeyManager(vaultUri: String) { private val client = new SecretClientBuilder() .vaultUrl(vaultUri) .buildClient() def getKey(keyName: String): Result[String] = { try { val secret = client.getSecret(keyName) Result.success(secret.getValue) } catch { case e: Exception => Result.failure(s"Failed to retrieve key from vault: ${e.getMessage}") } } } // Usage def getOpenAIKey(): Result[String] = { val vaultManager = new SecureKeyManager("https://myvault.vault.azure.net/") vaultManager.getKey("openai-api-key") } } ``` ### Key Rotation ```text object KeyRotation { case class KeyMetadata( key: String, provider: String, createdAt: Long, rotatedAt: Long, expiresAt: Long ) { def isExpired: Boolean = System.currentTimeMillis() > expiresAt def daysSinceRotation: Long = (System.currentTimeMillis() - rotatedAt) / (1000 * 60 * 60 * 24) } class KeyRotationManager { private var activeKey: KeyMetadata = _ private var rotationScheduler: Option[java.util.Timer] = None def rotateKey(newKey: String, provider: String): Unit = { val now = System.currentTimeMillis() val thirtyDaysFromNow = now + (30 * 24 * 60 * 60 * 1000) activeKey = KeyMetadata( key = newKey, provider = provider, createdAt = now, rotatedAt = now, expiresAt = thirtyDaysFromNow ) println(s"Key rotated for $provider. Valid until ${new java.util.Date(thirtyDaysFromNow)}") } def scheduleRotation(intervalDays: Int = 30): Unit = { val timer = new java.util.Timer() val delayMs = intervalDays * 24 * 60 * 60 * 1000L timer.scheduleAtFixedRate( new java.util.TimerTask { def run(): Unit = { println("Time to rotate API keys!") // In production: fetch new key from vault and rotate } }, delayMs, delayMs ) rotationScheduler = Some(timer) } } } ``` --- ## Input Validation ### Query Validation ```text object InputValidation { case class ValidationRule( name: String, validate: String => Boolean, errorMessage: String ) class InputValidator { private val rules = scala.collection.mutable.ListBuffer[ValidationRule]() def addRule(rule: ValidationRule): Unit = { rules += rule } def validate(input: String): Result[String] = { rules.find(!_.validate(input)) match { case Some(failedRule) => Result.failure(failedRule.errorMessage) case None => Result.success(input) } } } // Common validation rules def setupCommonValidations(): InputValidator = { val validator = new InputValidator() // Length check validator.addRule(ValidationRule( name = "length", validate = _.length <= 10000, errorMessage = "Input exceeds maximum length of 10000 characters" )) // No injection patterns validator.addRule(ValidationRule( name = "no_injection", validate = !containsSQLInjectionPattern(_), errorMessage = "Input contains potentially harmful patterns" )) // No excessive special characters validator.addRule(ValidationRule( name = "no_spam", validate = countSpecialChars(_) < 0.5, errorMessage = "Input contains too many special characters" )) validator } private def containsSQLInjectionPattern(input: String): Boolean = { val sqlPatterns = List("';", "--", "/*", "*/", "xp_", "sp_") sqlPatterns.exists(input.toLowerCase.contains(_)) } private def countSpecialChars(input: String): Double = { val specialCount = input.count(!_.isLetterOrDigit) specialCount.toDouble / input.length } } ``` ### Prompt Injection Protection ```text object PromptInjectionProtection { def detectPromptInjection(userInput: String): Boolean = { val injectionIndicators = List( "ignore all previous instructions", "forget everything", "system prompt", "you are now", "instead please", "role play as", "pretend you are" ) val lowerInput = userInput.toLowerCase injectionIndicators.exists(lowerInput.contains(_)) } def sanitizePrompt(userInput: String): Result[String] = { if (detectPromptInjection(userInput)) { Result.failure( "Your input was flagged as potentially malicious. " + "Please rephrase your request without system instructions." ) } else { Result.success(userInput) } } // Use prompt templating to separate user input def safePromptTemplate( userQuery: String, systemPrompt: String ): String = { s"""$systemPrompt User Query: [BEGIN USER INPUT] $userQuery [END USER INPUT] Please answer based only on the user's question above.""" } } ``` --- ## Output Sanitization ### PII Redaction Use the built-in `PIIMasker` and `PIIDetector` guardrails rather than hand-written patterns: they run before a query reaches the model and before an answer is returned, and are covered by tests. What they detect, how to attach them and their limits are in [Data Protection: personal data in prompts](../data-protection#personal-data-in-prompts). --- ## Audit Logging ### Comprehensive Audit Trail ```text object AuditLogging { import org.slf4j.LoggerFactory import java.time.Instant val auditLogger = LoggerFactory.getLogger("AUDIT") case class AuditEvent( timestamp: Instant, userId: String, action: String, resource: String, details: Map[String, String], success: Boolean, errorMessage: Option[String] = None ) def logAPICall( userId: String, provider: String, model: String, inputTokens: Int, outputTokens: Int ): Unit = { val event = AuditEvent( timestamp = Instant.now(), userId = userId, action = "API_CALL", resource = s"$provider/$model", details = Map( "input_tokens" -> inputTokens.toString, "output_tokens" -> outputTokens.toString ), success = true ) auditLogger.info( s"${event.timestamp} | User: ${event.userId} | " + s"Action: ${event.action} | Resource: ${event.resource}" ) } def logAccessToSensitiveData( userId: String, dataType: String, recordCount: Int, accessReason: String ): Unit = { auditLogger.info( s"${Instant.now()} | User: $userId | " + s"Accessed: $dataType ($recordCount records) | " + s"Reason: $accessReason" ) } def logAuthenticationAttempt( userId: String, success: Boolean, method: String ): Unit = { auditLogger.info( s"${Instant.now()} | User: $userId | " + s"Authentication: ${if (success) "SUCCESS" else "FAILED"} via $method" ) } } ``` ### Compliance Logging ```text object ComplianceLogging { case class DataProcessingLog( timestamp: Long, userId: String, dataCategory: String, operation: String, // PROCESS, STORE, SHARE, DELETE retentionDays: Int, userConsent: Boolean ) class ComplianceLogger { private val logs = scala.collection.mutable.ListBuffer[DataProcessingLog]() def logDataProcessing( userId: String, dataCategory: String, operation: String, retentionDays: Int ): Unit = { logs += DataProcessingLog( timestamp = System.currentTimeMillis(), userId = userId, dataCategory = dataCategory, operation = operation, retentionDays = retentionDays, userConsent = true ) } def getComplianceReport(days: Int = 30): String = { val cutoff = System.currentTimeMillis() - (days * 24 * 60 * 60 * 1000L) val recent = logs.filter(_.timestamp > cutoff) val operationCounts = recent.groupBy(_.operation).mapValues(_.length) val dataCategoryCounts = recent.groupBy(_.dataCategory).mapValues(_.length) s"""Compliance Report (last $days days): |Operations: $operationCounts |Data Categories: $dataCategoryCounts |Total Records Processed: ${recent.length}""".stripMargin } } } ``` --- ## Data Retention ### Automatic Data Expiration Agent thread history is pruned with a `RetentionPolicy` (`maxAge`, `maxCheckpoints`, `maxEvents`) and a store sweep, and a user's threads, memories and indexed documents are erased with `Agent.forget`, `MemoryStore.deleteMatching` and `VectorStore.deleteByFilter`. See [Data Protection: deleting a user's data](../data-protection#deleting-a-users-data). --- ## Best Practices ### ✅ Do's - **Never log API keys**: Even in debug mode - **Rotate keys regularly**: At least every 30 days - **Use key vaults**: Don't hardcode secrets - **Validate all input**: Both user queries and system inputs - **Sanitize all output**: Remove PII before returning - **Keep audit trails**: For compliance and debugging - **Use HTTPS only**: For all API communication - **Implement rate limiting**: To prevent abuse - **Monitor suspicious patterns**: Unusual access or usage - **Use least privilege**: Grant minimal required permissions ### ❌ Don'ts - **Don't store keys in code**: Use environment variables or vaults - **Don't trust user input**: Always validate and sanitize - **Don't output sensitive data**: Check for PII before returning - **Don't log full queries**: Log only hashed or redacted version - **Don't skip authentication**: Verify user identity - **Don't use weak passwords**: Enforce strong credential policies - **Don't ignore security updates**: Keep dependencies patched - **Don't expose error details**: Log full errors, show generic messages to users - **Don't test in production**: Use staging for security tests - **Don't assume perimeter security**: Defense in depth required --- ## Security Checklist ### ✅ Before Production - [ ] API keys loaded from secure vault, not code - [ ] HTTPS/TLS enforced for all API calls - [ ] Input validation on all user-facing endpoints - [ ] Output sanitization removes PII - [ ] Audit logging enabled for sensitive operations - [ ] Rate limiting configured - [ ] Authentication required for all endpoints - [ ] Data retention policy defined and enforced - [ ] Error messages don't expose system details - [ ] Secrets not logged anywhere - [ ] Security tests included in test suite - [ ] Penetration testing scheduled - [ ] Incident response plan documented --- ## See Also - [Production Monitoring](./production-monitoring) - Monitor security events - [Multi-Agent Orchestration](./multi-agent-orchestration) - Secure agent communication - [OWASP Top 10](https://owasp.org/www-project-top-ten/) - Web application security --- **Last Updated:** February 2026 **Status:** Production Ready --- Source: https://llm4s.org/guide/writing-a-provider # Writing a Provider How to publish your own LLM4S provider module - a chat client, an embedding provider, or both - against `llm4s-core`, without changing LLM4S itself. --- ## Before you start `llm4s-core` ships no provider. OpenAI, Anthropic, Ollama, Voyage and the rest are separate modules that register themselves through the same public SPI you will use, so a provider you publish as `my-llm4s-acme` works exactly like one built into this repository: users add the dependency, write a config section, and `LLMConnect.getClient` finds it. Check first whether you need a module at all: - **The vendor speaks the OpenAI `/chat/completions` API.** The generic `openai-compatible` provider in `llm4s-openai-compatible` may already cover it with config alone - see [OpenAI-compatible endpoints](providers#openai-compatible-endpoints). If the vendor bends the format (tool-call ids, system role, reasoning fields), the fix is an `OpenAICompatibleDialect` plus a descriptor in that module, contributed upstream - Mistral and Cohere are the examples. Do not write another copy of the chat-completions client. - **Anything else** gets a module of its own, which is what this page covers. Three rules hold throughout: 1. **Return `Result[A]`, never throw.** `type Result[+A] = Either[LLMError, A]`. Wrap calls that can throw with `Try(...).toResult` (`import org.llm4s.types.TryOps`). 2. **Never read the environment.** No `sys.env`, `System.getenv` or `ConfigFactory.load()` in provider code. Configuration arrives typed, through the descriptor; environment variables are bound in your module's `reference.conf`. 3. **Never edit `llm4s-core` to add a provider.** Everything below is an extension point. ## Worked examples Read one of these alongside this page: | Module | Shape | Start here when | |--------|-------|-----------------| | [`modules/providers/voyage`](https://github.com/llm4s/llm4s/tree/main/modules/providers/voyage) | Embeddings only, HTTP, no SDK | You only need embeddings - the smallest template | | [`modules/ollama`](https://github.com/llm4s/llm4s/tree/main/modules/ollama) | Chat + embeddings, HTTP, no SDK, model lister | You need a chat client | | [`modules/openai-compatible`](https://github.com/llm4s/llm4s/tree/main/modules/openai-compatible) | One client, many dialects | The vendor is OpenAI-compatible | ## Module layout ``` my-llm4s-acme/ ├── build.sbt # "org.llm4s" %% "llm4s-core", and "llm4s-provider-testkit" % Test └── src/ ├── main/ │ ├── resources/ │ │ ├── META-INF/services/org.llm4s.llmconnect.spi.Llm4sProviderModule │ │ └── reference.conf │ └── scala/com/acme/llm4s/ │ ├── AcmeConfig.scala # your ProviderConfig │ ├── AcmeProvider.scala # your ProviderDescriptor │ ├── AcmeClient.scala # your LLMClient │ └── Llm4sAcmeModule.scala # your Llm4sProviderModule └── test/scala/com/acme/llm4s/ └── Llm4sAcmeModuleSpec.scala ``` Use your own package. The in-repo modules keep `org.llm4s.*` for source compatibility with earlier releases; a third-party module has no reason to, and anything `private[llm4s]` stays out of reach either way (see [Stability](#stability)). ## Registration ### The module class `org.llm4s.llmconnect.spi.Llm4sProviderModule` is the unit of discovery: one per artifact, listing whatever it supplies. Both members default to `Nil`. (not compile-checked) ```scala trait Llm4sProviderModule: def chatProviders: Seq[ProviderDescriptor] = Nil def embeddingProviders: Seq[EmbeddingProviderDescriptor] = Nil ``` Implement it as a **`class` with a public no-arg constructor, not an `object`** - `java.util.ServiceLoader` instantiates the class it is given, and an `object` has no such constructor: (not compile-checked) ```scala package com.acme.llm4s import org.llm4s.llmconnect.spi.{ EmbeddingProviderDescriptor, Llm4sProviderModule, ProviderDescriptor } final class Llm4sAcmeModule extends Llm4sProviderModule: override def chatProviders: Seq[ProviderDescriptor] = Seq(AcmeProvider) override def embeddingProviders: Seq[EmbeddingProviderDescriptor] = Seq(AcmeEmbeddings) ``` ### The services file `src/main/resources/META-INF/services/org.llm4s.llmconnect.spi.Llm4sProviderModule`, one line: ``` com.acme.llm4s.Llm4sAcmeModule ``` `ProviderRegistry.discover()` (and `ProviderRegistry.default`, its cached result) now finds the module whenever the jar is on the classpath. Tell users who build fat jars to *merge* services files (`MergeStrategy.filterDistinctLines` in sbt-assembly, `ServicesResourceTransformer` in maven-shade); where that is impossible they register explicitly: (not compile-checked) ```scala given ProviderRegistry = ProviderRegistry.ofModules(new Llm4sAcmeModule) // or extend another registry: ProviderRegistry.default.withModule(new Llm4sAcmeModule) ``` If two modules supply the same id, the last registered wins and `registry.report.collisions` records it. ### `reference.conf`: binding the vendor key Credentials belong to a vendor, keyed by provider id. Your module's `reference.conf` binds the vendor's conventional variable to `llm4s.credentials..apiKey`; any chat section or embeddings block without an `apiKey` of its own falls back to it: ```hocon # my-llm4s-acme defaults. HOCON merges this with every other reference.conf on the classpath. llm4s { credentials { acme { apiKey = ${?ACME_API_KEY} } } # Embeddings are keyed by provider id, so their env bindings can live here too. embeddings { acme { baseUrl = ${?ACME_EMBEDDING_BASE_URL} model = ${?ACME_EMBEDDING_MODEL} } } } ``` Do not put defaults (base URL, default model) here - those are code, on the config spec. Chat sections are keyed by the *user's* instance name, which a `reference.conf` cannot know. ## The chat descriptor `org.llm4s.llmconnect.spi.ProviderDescriptor` is the whole chat extension point: (not compile-checked) ```scala trait ProviderDescriptor: def id: ProviderId def aliases: Set[String] = Set.empty def configSpec: ProviderConfigSpec def features: ProviderFeatures = ProviderFeatures.default def modelLister: Option[ProviderModelLister] = None def buildConfig(providerName: String, section: ProvidersConfigModel.NamedProviderConfig)(using ContextWindowResolver ): Result[ProviderConfig] def buildClient(config: ProviderConfig, options: LlmClientOptions)(using ModelRegistryService ): Result[LLMClient] ``` `buildConfig` receives a section that has **already been validated** against `configSpec`: required fields are present, the API key has fallen back to `llm4s.credentials..apiKey`, and `section.extras` holds exactly your declared extra keys, defaults applied. `buildClient` must check the config's type rather than cast - `ProviderDescriptor.expectConfig` does that. ### Workload identity A provider that can authenticate with a workload identity token (a SPIFFE JWT-SVID, say) declares the keys it reads from a section's `auth` block in `ProviderConfigSpec.authExtras`. Core parses `identityTokenFile` / `identityToken` itself and hands the rest to `buildConfig` as `section.auth`; read a key with `ProviderDescriptor.requireAuthExtra`. A section sets `apiKey` or `auth`, never both, and a section with `auth` never consults the shared credential. `TokenExchange.provider` gives an RFC 8693 exchange with caching and single-flight refresh, and `FakeTokenExchangeServer` and `TestJwt` in the testkit test it without a network. An SDK that does its own federation takes the identity token's file path instead (see `AnthropicProvider`). The configuration side is `@Stable` ([#1818](https://github.com/llm4s/llm4s/issues/1818)): `AuthConfig`, `IdentitySource`, `TokenExchangeConfig`, `AccessToken` and the `AccessTokenProvider` trait, which a Stable `NamedProviderConfig`, `ProviderDescriptor.requireAuthExtra` and `OpenAICompatibleClient.Credential` carry. The machinery behind them - `TokenExchange`, `CachingAccessTokenProvider` and `IdentityTokenSource` - is still `@Experimental`, so keep it out of your own Stable signatures. An `AccessTokenProvider` should return a failure as a `Left`; `OpenAICompatibleClient` and `CachingAccessTokenProvider` turn one that throws into an `AuthenticationError`. ### `ProviderConfigSpec` and `ProviderConfigKey` The spec says what a section needs. The fields a provider author uses: | Field | Meaning | |-------|---------| | `requiresApiKey` | the section must resolve an `apiKey` (its own, or the shared credential) | | `requiresBaseUrl` | the section must set `baseUrl`; only when there is no `defaultBaseUrl` (Ollama) | | `defaultBaseUrl` | used when the section omits `baseUrl` - the single source for your default endpoint | | `baseUrlExample`, `baseUrlEnv` | shown in the missing-`baseUrl` message | | `apiKeyEnv` | the variables your `reference.conf` binds, named in the missing-key error | | `extras` | your provider-specific keys, as `ProviderConfigKey`s | | `authExtras` | the keys you read from a section's `auth` block, as `ProviderConfigKey`s; empty (the default) means the provider rejects `auth` | `ProviderConfigSpec.apiKeyAndDefaultBaseUrl(defaultBaseUrl, apiKeyEnv)` builds the common shape. Anything beyond the built-in fields (`provider`, `model`, `baseUrl`, `apiKey`, `headers`, `timeouts` - `ProviderConfigSpec.BuiltinKeys`) is declared as an extra rather than smuggled through a built-in field: (not compile-checked) ```scala ProviderConfigKey.required("project", "the project that owns your Acme deployment") ProviderConfigKey.optional("region", "the Acme region serving the model", default = Some("eu-west")) ``` `ProviderConfigKey` also takes `env` (a variable to suggest in the missing-key message) and `deprecatedAliases` (old names, accepted with a warning), as named arguments or afterwards with `withEnv` / `withDeprecatedAliases`. Undeclared keys in a section are reported as unknown and not passed on. Values are strings; parse numbers or booleans in `buildConfig` and return a `ConfigurationError` for a malformed one. Read the values with `section.extra("region")` (an `Option[String]`) or `ProviderDescriptor.requireExtra(providerName, section, "region")` (a `Result[String]`). ### The descriptor (not compile-checked) ```scala package com.acme.llm4s import org.llm4s.config.ProvidersConfigModel.NamedProviderConfig import org.llm4s.llmconnect.{ LLMClient, LlmClientOptions } import org.llm4s.llmconnect.config.{ ContextWindowResolver, ProviderConfig } import org.llm4s.llmconnect.spi.* import org.llm4s.model.ModelRegistryService import org.llm4s.types.ProviderModelTypes.ProviderId import org.llm4s.types.Result object AcmeProvider extends ProviderDescriptor: val id: ProviderId = ProviderId("acme") override val aliases: Set[String] = Set("acme-ai") val configSpec: ProviderConfigSpec = ProviderConfigSpec( requiresApiKey = true, defaultBaseUrl = Some("https://api.acme.example/v1"), apiKeyEnv = Seq("ACME_API_KEY"), // what reference.conf binds extras = Seq( ProviderConfigKey.optional("region", "the Acme region serving the model", default = Some("eu-west")) ) ) // Say so if the client cannot stream or call tools; the default claims both. override val features: ProviderFeatures = ProviderFeatures(streaming = true, toolCalling = false) def buildConfig(providerName: String, section: NamedProviderConfig)(using ContextWindowResolver ): Result[ProviderConfig] = for apiKey <- ProviderDescriptor.requireApiKey(providerName, section) baseUrl <- ProviderDescriptor.resolveBaseUrl(providerName, section, configSpec) region <- ProviderDescriptor.requireExtra(providerName, section, "region") config <- AcmeConfig.fromValues(apiKey, section.model.asString, baseUrl, region) yield config def buildClient(config: ProviderConfig, options: LlmClientOptions)(using ModelRegistryService ): Result[LLMClient] = ProviderDescriptor.expectConfig[AcmeConfig](id, config).map(new AcmeClient(_, options)) ``` ### Your `ProviderConfig` `org.llm4s.llmconnect.config.ProviderConfig` is an open trait: `providerId`, `model`, `contextWindow`, `reserveCompletion`, `endpointUrl` and `withModel`. Resolve the window from the model registry with `ContextWindowResolver` (the `using` parameter `buildConfig` receives), and keep the key out of `toString`: (not compile-checked) ```scala import org.llm4s.error.ConfigurationError final case class AcmeConfig( apiKey: String, model: String, baseUrl: String, region: String, contextWindow: Int, reserveCompletion: Int ) extends ProviderConfig: val providerId: ProviderId = ProviderId("acme") def endpointUrl: Option[String] = Some(baseUrl) def withModel(model: String): AcmeConfig = copy(model = model) override def toString: String = s"AcmeConfig(***, $model, $baseUrl, $region)" object AcmeConfig: def fromValues(apiKey: String, model: String, baseUrl: String, region: String)(using resolver: ContextWindowResolver ): Result[AcmeConfig] = if apiKey.trim.isEmpty then Left(ConfigurationError("Acme apiKey must be non-empty", List("apiKey"))) else val (window, reserve) = resolver.resolve( lookupProviders = Seq("acme"), modelName = model, defaultContextWindow = 128000, defaultReserve = 4096, fallbackResolver = _ => (128000, 4096) // when the registry does not know the model ) Right(AcmeConfig(apiKey, model, baseUrl, region, window, reserve)) ``` ## What the user writes With `my-llm4s-acme` on the classpath and `ACME_API_KEY` set, a section needs only `provider` and `model`; extras sit beside the built-in fields: ```hocon llm4s { providers { provider = "acme-main" acme-main { provider = "acme" model = "acme-large" region = "us-east" # your ProviderConfigKey; omit it to get "eu-west" } acme-team { # a second account sets its own key, which wins provider = "acme" model = "acme-small" apiKey = ${?ACME_TEAM_API_KEY} } } } ``` (not compile-checked) ```scala for config <- Llm4sConfig.defaultProvider() client <- LLMConnect.getClient(config) yield client ``` ## Shared plumbing for clients These live in `llm4s-core` and are part of the provider-author SPI. Use them rather than re-implementing their behaviour, so your provider reports metrics, errors and costs the way every other provider does. ### `BaseLifecycleLLMClient` and `MetricsRecording` `org.llm4s.llmconnect.BaseLifecycleLLMClient` (extends `LLMClient with MetricsRecording`) gives an idempotent `close()`, a closed-state check, and metrics around each call. You supply: (not compile-checked) ```scala protected def metrics: MetricsCollector // from MetricsRecording; use options.metrics protected def clientDescription: String // "Acme client for model x", for the closed error protected def providerName: String // metrics label, e.g. "acme" protected def modelName: String protected def releaseResources(): Unit = () // called once, from close() ``` Wrap `complete` and `streamComplete` bodies in `completeWithMetrics(operation: => Result[Completion]): Result[Completion]`, which fails a closed client, times the call, and records tokens and `Completion.estimatedCost` on success. For non-`Completion` calls, `MetricsRecording.withMetrics(provider, model, operation, extractUsage, extractCost)` does the same. ### `ProviderExchangeRecorder` and `ProviderExchangeLogging` `LlmClientOptions.exchangeLogging` is a `ProviderExchangeLogging` (`Disabled`, or `Enabled(sink)`) - the user's opt-in to capture raw request/response bodies. Honour it by calling, once per request: (not compile-checked) ```scala ProviderExchangeRecorder.record( exchangeLogging = options.exchangeLogging, provider = providerName, model = Some(config.model), startedAt = startedAt, // java.time.Instant taken before the call requestBody = requestText, responseBody = Some(responseText), result = result // the Result you are about to return ) ``` It is a no-op when disabled and never fails your call if the sink throws. Do not put credentials in the request body you record; send them in headers. ### `Llm4sHttpClient` `org.llm4s.http.Llm4sHttpClient` is the JDK-backed HTTP client (`Llm4sHttpClient.create()`), with `get`, `post`, `postBytes`, `postMultipart`, `put`, `delete`, `postRaw` and `postStream`. Take one as a constructor parameter so tests can inject a stub. (not compile-checked) ```scala def post( url: String, headers: Map[String, String] = Map.empty, body: String = "", timeout: FiniteDuration = 10.seconds ): Result[HttpResponse] ``` Every method returns a `Result` and never throws for a transport failure, so there is no `try`/`catch` to write: | Failure | `Left` | |---|---| | request or connection timed out | `TimeoutError` (carries the timeout) | | connection refused, unknown host, other I/O error | `NetworkError` | | invalid URL, header or timeout; unreadable multipart file | `ValidationError` | | thread interrupted (the interrupt flag is kept) | `CancelledError` | A non-2xx status is **not** an error at this layer: it is a `Right` response for you to inspect (`HttpResponse.ensureSuccess`, or `HttpErrorMapper` below). Timeouts are `scala.concurrent.duration.FiniteDuration`; the default is 10 seconds, and 10 minutes for `postStream`. `HttpResponse`, `HttpRawResponse` and `StreamingHttpResponse` all carry `headers` (lower-case keys); `response.header("Retry-After")` looks one up case-insensitively. A `StreamingHttpResponse`'s body is yours to close, on an error status too. ### Honouring the `timeouts` block Every provider section, and every embedding section, may carry `timeouts { request = ..., stream = ... }`. You do not read the block: the loader parses it into `NamedProviderConfig.timeouts` (checked to be positive and finite) and applies it to the config your descriptor builds, by calling `ProviderConfig.withTimeouts`. To honour it, carry a `ProviderTimeouts` in your config type and pass it on when you call the HTTP client: (not compile-checked) ```scala final case class AcmeConfig( apiKey: String, model: String, baseUrl: String, override val timeouts: ProviderTimeouts = ProviderTimeouts.default ) extends ProviderConfig: // ... providerId, contextWindow and the rest ... override def withTimeouts(timeouts: ProviderTimeouts): AcmeConfig = copy(timeouts = timeouts) // in the client: your own default stays when the section sets none httpClient.post(url, headers, body, timeout = config.timeouts.requestOr(2.minutes)) httpClient.postStream(url, headers, body, timeout = config.timeouts.streamOr(10.minutes)) ``` A config that does not override `withTimeouts` ignores the block, since the default returns `this`; a provider with no timeouts to offer needs to do nothing. An embedding provider reads `EmbeddingProviderConfig.timeouts.requestOr(...)`, and only `request` has a meaning there. Methods added to the trait after 1.0 will have default implementations, so a test double that implements it keeps compiling. ### `HttpErrorMapper` (not compile-checked) ```scala HttpErrorMapper.mapHttpError( statusCode: Int, body: String, provider: String, headers: Map[String, Seq[String]] = Map.empty ): Result[Nothing] ``` Maps a non-2xx response to the standard error types - 401/403 `AuthenticationError`, 429 `RateLimitError`, 400 `ValidationError`, anything else `ServiceError` - pulling a message out of common JSON error shapes, redacted and truncated. Retry and fallback logic keys off these types, so use it rather than inventing your own. Pass the response's `headers`: a `Retry-After` (delta-seconds or an HTTP-date) becomes the error's `retryAfter`, a `FiniteDuration` - on a 429's `RateLimitError`, or a 503's (any other status's) `ServiceError` - so retries wait as long as the provider asked rather than a guessed backoff. ### `CostEstimator` (not compile-checked) ```scala CostEstimator.estimate(model: String, usage: TokenUsage)(using ModelRegistryService): Option[Double] CostEstimator.estimateFromMetadata(metadata: Option[ModelMetadata], usage: TokenUsage): Option[Double] ``` Prices a call from the model registry, including cached and reasoning tokens. Put the result in `Completion.estimatedCost`; `completeWithMetrics` records it. ### `RequestTransformer` and `TransformationResult` `org.llm4s.model.RequestTransformer` applies what the model registry knows about a model - temperature range, disallowed parameters, no tools, no system messages, no structured output, no native streaming - before you encode a request: (not compile-checked) ```scala RequestTransformer.default(service: ModelRegistryService): RequestTransformer RequestTransformer.withOverrides(overrides: Map[String, ModelCapabilities], service): RequestTransformer RequestTransformer.adjusted(service)(adjust: (String, ModelCapabilities) => ModelCapabilities): RequestTransformer ``` Use `adjusted` for vendor rules the registry does not record: you receive the model id and the registry's capabilities and return the ones to apply. `llm4s-openai`'s `OpenAIModelRules` is the example - the o-series take temperature 1 only and no system message, whatever the registry says: (not compile-checked) ```scala RequestTransformer.adjusted(service) { (modelId, caps) => if modelId.startsWith("acme-reasoner") then caps.withSupportsSystemMessages(false) else caps } ``` `TransformationResult.transform(modelId, options, messages, transformer, dropUnsupported)` runs the option and message transforms in one call and returns the transformed `options`, `messages` and `requiresFakeStreaming`. ### `EmbeddingProvider` (not compile-checked) ```scala trait EmbeddingProvider: def embed(request: EmbeddingRequest): Result[EmbeddingResponse] def embedMultimodal(request: MultimediaEmbeddingRequest): Result[EmbeddingResponse] // defaults to a 501 error ``` What an `EmbeddingProviderDescriptor.build` returns. Report failures as `EmbeddingError(code, message, provider)` - but not a cancellation: an interrupted `Llm4sHttpClient` call returns `CancelledError`, and mapping it into an `EmbeddingError` (as `left.map(err => EmbeddingError(...))` does) hides it. Pass it through, as the Voyage, Jina, Ollama and OpenAI providers do, and see [Cancellation](#cancellation). `request.purpose` is an `InputPurpose`, `Document` (the default) or `Query`: whether the texts are to be indexed or are a search query. If your vendor's API embeds the two differently, map it onto the vendor's own parameter in `embed` (Voyage and Cohere send `input_type`, Jina sends `task`), and let a typed setting of the provider, if it has one, win over it, because that is a deliberate choice and can say more than two values can. If your models embed both alike, ignore it and do not send a field for it. Test the exact wire field for both purposes against a local server, as `VoyageAIInputTypeSpec` does. ### Streaming: `SSEParser`, `StreamingAccumulator`, `StreamingToolArgumentParser` - `SSEParser.createStreamingParser()` returns an incremental parser: `addChunk(text)`, then drain `nextEvent()` / `hasEvents`, and `flush()` at end of stream. Each `SSEEvent` has `data`, `event`, `id` and `retry`. `SSEParser.parseEvent` / `parseStream` handle whole strings. - `StreamingAccumulator.create()` folds `StreamedChunk`s into a final `Completion`: `addChunk(chunk)`, `updateTokens(prompt, completion)` (or `updateTokensWithThinking`) when usage arrives, then `toCompletion: Result[Completion]`. It keys tool calls by id, so **every tool-call chunk must carry its call's id** - if the wire format identifies continuations only by index, map index to id yourself; a chunk with an empty id is skipped. It is mutable: one per request. - Pass each streamed argument fragment to the accumulator as-is, as `ujson.Str(raw)` (`ujson.Obj()` for an empty one): the accumulator joins the fragments and parses the whole. Do not parse a fragment on its own - a fragment that happens to be valid JSON (`":"`, `"Paris"`, `1`) would lose its quotes or be dropped. `StreamingToolArgumentParser.parse(raw: String): ujson.Value` is for a reassembled argument string (`{}` for empty, the parsed JSON when complete, the raw string otherwise); `StreamingAccumulator` uses it on the joined arguments. ### `ProviderModelLister` (not compile-checked) ```scala trait ProviderModelLister: def listModels(config: NamedProviderConfig, httpClient: Llm4sHttpClient): Result[List[DiscoveredModel]] ``` Return one from `ProviderDescriptor.modelLister` if the vendor has a model-listing endpoint. If it serves the OpenAI `/models` shape, `ProviderModelListers.openAICompatible(provider, defaultBaseUrl, ...)` in `llm4s-openai-compatible` builds one for you (add that dependency); its `sectionHeaders` parameter derives request headers from the section, e.g. from one of your extras. ### Redaction llm4s's own credential redaction (`org.llm4s.util.Redaction`) is internal and not part of the SPI. `HttpErrorMapper` already runs provider error bodies through it, so error text you build with it is redacted; beyond that, keep credentials out of anything you log. ## A minimal client (not compile-checked) ```scala final class AcmeClient( config: AcmeConfig, clientOptions: LlmClientOptions, http: Llm4sHttpClient = Llm4sHttpClient.create() )(using registry: ModelRegistryService) extends BaseLifecycleLLMClient: protected val metrics: MetricsCollector = clientOptions.metrics protected def clientDescription: String = s"Acme client for model ${config.model}" protected def providerName: String = "acme" protected def modelName: String = config.model private val transformer = RequestTransformer.default(registry) private val headers = Map("Authorization" -> s"Bearer ${config.apiKey}", "Content-Type" -> "application/json") def complete(conversation: Conversation, options: CompletionOptions): Result[Completion] = completeWithMetrics { TransformationResult .transform(config.model, options, conversation.messages, transformer) .flatMap(t => send(AcmeWire.encode(config.model, t.messages, t.options))) } def streamComplete( conversation: Conversation, options: CompletionOptions, onChunk: StreamedChunk => Unit ): Result[Completion] = completeWithMetrics(...) // postStream + SSEParser + StreamingAccumulator, as Ollama's client does def getContextWindow(): Int = config.contextWindow def getReserveCompletion(): Int = config.reserveCompletion private def send(requestText: String): Result[Completion] = val startedAt = Instant.now() val response = http.post(s"${config.baseUrl}/chat", headers, requestText, timeout = 120.seconds) val result = response.flatMap { r => if r.statusCode / 100 == 2 then AcmeWire.decode(r.body).map(withCost) else HttpErrorMapper.mapHttpError(r.statusCode, r.body, providerName, r.headers) } ProviderExchangeRecorder.record( clientOptions.exchangeLogging, providerName, Some(config.model), startedAt, requestText, response.toOption.map(_.body), result ) result private def withCost(c: Completion): Completion = c.withEstimatedCost(c.usage.flatMap(CostEstimator.estimate(config.model, _))) ``` `AcmeWire` stands for your own request encoding and response decoding. ### Content parts A `UserMessage` and a `ToolMessage` carry `parts: Vector[ContentPart]`: `Text`, `Image` and `Document`, each image or document from a `MediaSource` (`Url`, `Inline` bytes, or a `ProviderFile` uploaded to a named provider). Your encoder sends every part your API takes, in each role it takes it, and fails the request **before sending it** with `UnsupportedContentPartError(providerName, part.kind, role.name)` for any other: never drop a part, and never turn one into text. Send a `ProviderFile` only when its `provider` is your provider id. A message with exactly one text part should encode as your API's plain text form, as before; `message.content` is the text view. The matrix of what each llm4s provider sends is in [Multimodal content](multimodal). ## The embedding descriptor `org.llm4s.llmconnect.spi.EmbeddingProviderDescriptor` is the embedding half. Its config lives at `llm4s.embeddings.` and is selected with `EMBEDDING_MODEL=/`. Usually you declare an `EmbeddingConfigSpec` and implement `build`; the default `buildConfig` resolves model, base URL and key against the spec. (not compile-checked) ```scala object AcmeEmbeddings extends EmbeddingProviderDescriptor: val id: ProviderId = ProviderId("acme") // chat and embedding ids are separate namespaces override val configSpec: EmbeddingConfigSpec = EmbeddingConfigSpec( requiresApiKey = true, defaultBaseUrl = Some("https://api.acme.example/v1"), defaultModel = Some("acme-embed-1"), apiKeyEnv = Seq("ACME_API_KEY"), modelEnv = Some("ACME_EMBEDDING_MODEL") ) // What ModelDimensionRegistry answers from; override dimensionsOf for model-name variants. override val modelDimensions: Map[String, Int] = Map("acme-embed-1" -> 1024) def build(config: EmbeddingProviderConfig): Result[EmbeddingProvider] = Right(AcmeEmbeddingProvider(config, Llm4sHttpClient.create())) ``` `EmbeddingConfigSpec.defaultApiKey` is for a provider that takes a key but needs no real one (local Ollama). Override `buildConfig(section, modelOverride)` only when the config cannot be expressed with the spec. ## Testing: `Llm4sModuleSpec` Every provider module in this repository proves its registration in one spec, and yours should too - it replaces the exhaustivity check the compiler gave when providers were a closed `enum`. It shows that the module is discovered, that it is the only module supplying its ids, that it can be registered explicitly, that each descriptor gets from config to client, and that your `reference.conf` binds the variable `apiKeyEnv` names. Those checks ship as **`llm4s-provider-testkit`**, the same ones the in-repo provider modules run. Add it in test scope; it brings ScalaTest with it: (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-provider-testkit" % llm4sVersion % Test ``` The testkit **requires JDK 21**: `assertCancelsWhenInterrupted` and its siblings run the call on a virtual thread (`Thread.ofVirtual`), and `LocalProviderTestServer` answers on `Executors.newVirtualThreadPerTaskExecutor`, both JDK 21 APIs. The build that runs your `Llm4sModuleSpec` needs a JDK 21 toolchain, whatever your provider module itself targets (the library's own minimum JDK is settled in [#1493](https://github.com/llm4s/llm4s/issues/1493)). It has these parts, all in `org.llm4s.testkit`: | | What it gives you | |---|---| | `ProviderModuleChecks` | The checks, as assertions: `assertModule` (= `assertDiscovered` + `assertSoleSupplier` + `assertRegistrableWith`), `assertBuildsClient` / `buildClient`, `assertRefusesForeignConfig`, `assertStreams`, `assertCancelsWhenInterrupted`, `assertCancelsStreamWhenInterrupted`, `assertOneToolResultPerCall`, `assertContentParts` (each `ContentPartCase` your client declares reaches a local server with its payload; every other fails with `UnsupportedContentPartError` and sends nothing), `assertBuildsEmbeddingProvider`, `assertCredentialBindings`, `assertEmbeddingCredentialBindings`. Mix the trait into a spec of any ScalaTest style, or call the companion object. A failure points at the line in your spec. | | `ProviderTestConfig` | `loadSection`, `loadProvider` and `loadEmbeddings`: config loaded as an application loads it, from a HOCON string over every `reference.conf` on the classpath, with `${?VAR}` resolved against a `Map` you pass - never the real environment, so an exported `ACME_API_KEY` on your machine cannot make a test pass that fails in CI. | | `CredentialsRoundTrip` | `chatSectionKey`, `chatBindings`, `embeddingsKey`, `embeddingBindings`: which key a section or embeddings block with no `apiKey` of its own ends up with, for cases the assertions do not cover - an alias, two variables in precedence order, a variable that must *not* be picked up. | | `LocalProviderTestServer` | `withServer(path)(handler)(baseUrl => ...)`, `sendJsonResponse`, `sendSseResponse`, `holdOpen` / `streamThenHold` (requests that never finish, released when `withServer` ends), OpenAI-format bodies and an Anthropic-format `anthropicMessage`: the JDK's HTTP server on an ephemeral port, to point a client at. `ToolResultContract` (with `ToolMessageFormat`) reads a request body for the tool-result contract below. | (not compile-checked) ```scala package com.acme.llm4s import org.llm4s.config.ProvidersConfigModel.NamedProviderConfig import org.llm4s.llmconnect.spi.ProviderRegistry import org.llm4s.testkit.{ CredentialsRoundTrip, ProviderModuleChecks, ProviderTestConfig } import org.llm4s.testkit.LocalProviderTestServer.{ sendSseResponse, withServer } import org.llm4s.types.ProviderModelTypes.* import org.scalatest.matchers.should.Matchers import org.scalatest.wordspec.AnyWordSpec class Llm4sAcmeModuleSpec extends AnyWordSpec with Matchers with ProviderModuleChecks: private val section = NamedProviderConfig( provider = AcmeProvider.id, model = ModelName("acme-large"), baseUrl = None, apiKey = Some(ApiKey("test-key")), extras = Map("region" -> "eu-west") // built in code, so no validation fills the default ) private val streamBody = "data: {\"delta\":\"Hi\"}\n\ndata: [DONE]\n\n" // Acme's wire format "the my-llm4s-acme services entry" should { // Discovered by ProviderRegistry.discover(), every id and alias resolving to your // descriptors; no other module supplying "acme"; and ProviderRegistry.ofModules works. "register the module" in assertModule(new Llm4sAcmeModule) } "AcmeProvider" should { "build an AcmeClient from a section, and refuse another provider's config" in { assertBuildsClient(AcmeProvider, section) shouldBe an[AcmeClient] assertRefusesForeignConfig(AcmeProvider) } "really stream" in { withServer("/v1/chat")(exchange => sendSseResponse(exchange, streamBody)) { baseUrl => assertStreams(assertBuildsClient(AcmeProvider, section.withBaseUrl(BaseUrl(baseUrl)))) } } "load from a named section as an application does, extras defaulted" in { given ProviderRegistry = ProviderRegistry.default ProviderTestConfig .loadProvider( "acme-main", """llm4s.providers.acme-main { provider = "acme", model = "acme-large" }""", Map("ACME_API_KEY" -> "test-key") ) .map(_.asInstanceOf[AcmeConfig].region) shouldBe Right("eu-west") } } "the my-llm4s-acme reference.conf" should { "bind ACME_API_KEY to llm4s.credentials.acme.apiKey, for chat and embeddings" in { assertCredentialBindings(AcmeProvider) assertEmbeddingCredentialBindings(AcmeEmbeddings, "acme-embed-1") } "give the acme-ai alias the same key" in { given ProviderRegistry = ProviderRegistry.default CredentialsRoundTrip.chatSectionKey("acme-ai", Map("ACME_API_KEY" -> "k")) shouldBe Right(Some("k")) } } ``` `assertCredentialBindings` loads a section with no `apiKey` once per variable in `apiKeyEnv`, with only that variable set, so the shared credential is the only place a key can come from; when it fails it names the `reference.conf` line that is missing. Test the client itself against `LocalProviderTestServer` too, including that an error status maps to the right `LLMError`. ### Cancellation A provider must return `Left(CancelledError)`, with the interrupt flag kept, when its thread is interrupted. It must not throw `InterruptedException` or clear the flag. A client that extends `BaseLifecycleLLMClient` gets this from `completeWithMetrics`; `Llm4sHttpClient` already returns `CancelledError` for an interrupted request or stream read. Your module spec should run `assertCancelsWhenInterrupted` and `assertCancelsStreamWhenInterrupted` against `LocalProviderTestServer.holdOpen` and `streamThenHold`; an embedding provider runs `assertEmbeddingCancelsWhenInterrupted`, and any other client call that returns a `Result` (a reranker, a speech or image client) `assertCallCancelsWhenInterrupted("name")(call)`. A client that does not extend `BaseLifecycleLLMClient` can use the public helpers `CancelledError.attempt`, `CancelledError.whenInterrupted`, `CancelledError.fromThrowable` and `CancelledError.isCancellation`. `fromThrowable` and `isCancellation` only classify: they never set the flag, so if your code catches an `InterruptedException` itself, restore the flag with `Thread.currentThread().interrupt()`. A bare `InterruptedIOException`, such as OkHttp's call timeout, is not a cancellation unless the thread is interrupted or an `InterruptedException` lies beneath it. ### Tool results An agent's tool loop sends a conversation in which every tool call has exactly one result, straight after the assistant message that made it, and relies on your client to send it that way: OpenAI's format puts one `role: "tool"` message per call after the assistant message, Anthropic's one `tool_result` block per call at the start of the next user turn. A chat provider whose API takes tools runs `assertOneToolResultPerCall(format, response, path)(baseUrl => client)`: it sends each of `ToolResultContract.cases` - a parallel batch with error, rejection and denial results, an unknown tool, provider call ids reused across turns, a handoff, a refused handoff batch - to a `LocalProviderTestServer` answering with `response`, and fails on any `ToolResultContract.violations` in the body the server received, and when the body carries fewer calls or native results than the conversation: a client that drops a call it cannot pair, or sends a result as plain text, would hide a broken conversation rather than send it. `ToolMessageFormat` is `OpenAIChat` or `AnthropicMessages`; a provider with another wire format checks its own encoding against `ToolResultContract.cases` the same way. (not compile-checked) ```scala "send exactly one tool result per call" in { assertOneToolResultPerCall(ToolMessageFormat.OpenAIChat, LocalProviderTestServer.openAICompletion("done")) { baseUrl => assertBuildsClient(AcmeProvider, section.withBaseUrl(BaseUrl(baseUrl))) } } ``` ## Stability `llm4s-core` reaches a binary-compatibility baseline (MiMa) at a pre-1.0 release, once the designs are final, and freezes at 1.0. The provider-author SPI described on this page is **public and frozen at 1.0**: binary-compatible across all 1.x releases, so a provider compiled against 1.0 keeps working. That covers: - the registration SPI in `org.llm4s.llmconnect.spi` - `Llm4sProviderModule`, `ProviderDescriptor`, `ProviderConfigSpec`, `ProviderConfigKey`, `ProviderFeatures`, `EmbeddingProviderDescriptor`, `EmbeddingConfigSpec`, `EmbeddingProviderSection`, `ProviderRegistry`; - `ProviderConfig`, `NamedProviderConfig`, `ContextWindowResolver`, `LlmClientOptions`; - `BaseLifecycleLLMClient`, `MetricsRecording`, `ProviderExchangeRecorder`, `ProviderExchangeLogging`, `HttpErrorMapper`, `CostEstimator`, `EmbeddingProvider`, `StreamingAccumulator`, `SSEParser`, `StreamingToolArgumentParser`, `Llm4sHttpClient`, `ProviderModelLister`, `RequestTransformer` and `TransformationResult`. `llm4s-provider-testkit` is **Beta**, not part of the frozen SPI: it is a test-scope dependency, so a change to it can break your tests but never your users, and it may gain checks in a minor release (with a migration note). Anything `private[llm4s]` - `ProviderResultOps`, for example - is internal, may change in any release, and cannot be reached from your package anyway. Do not work around that by declaring your code in `org.llm4s`. The OpenAI-format helpers - `ResponseFormatMapper`, `ToolCallDeserializer` and `StandardToolCallDeserializer`, `OpenAICompatibleClient` and its dialects - ship in `llm4s-openai-compatible`, not in core. Depending on that module for them puts it on your users' classpath; for an OpenAI-compatible vendor, contribute a dialect there instead. ## Checklist - [ ] `Llm4sProviderModule` as a `class` with a no-arg constructor, named in `META-INF/services/org.llm4s.llmconnect.spi.Llm4sProviderModule` - [ ] `reference.conf` binds the vendor variable to `llm4s.credentials..apiKey`, and `apiKeyEnv` names the same variable - [ ] defaults (base URL, model) on the spec, not in `reference.conf` - [ ] provider-specific keys declared as `ProviderConfigKey` extras - [ ] `ProviderFeatures` honest about streaming and tool calling - [ ] client built on `BaseLifecycleLLMClient`, errors via `HttpErrorMapper`, cost via `CostEstimator`, exchanges via `ProviderExchangeRecorder` - [ ] no exceptions escape, no environment reads - [ ] `Llm4sModuleSpec`, on `llm4s-provider-testkit`, covering discovery, sole ownership, explicit registration, the config-to-client round trip and the credential binding - [ ] a chat provider that takes tools runs `assertOneToolResultPerCall` --- Source: https://llm4s.org/guide/cats-effect # cats-effect Integration The `llm4s-effect` module wraps the synchronous `LLMClient` and `Agent` APIs in [cats-effect](https://typelevel.org/cats-effect/) `IO` (or any `Async[F]` type), keeping the compute thread pool free while long-running LLM calls block on the dedicated blocking pool. ## Dependency (not compile-checked) ```scala // build.sbt libraryDependencies += "org.llm4s" %% "llm4s-effect" % "" ``` ## LLMClientIO `LLMClientIO[F[_]]` is the cats-effect wrapper for `LLMClient`. ### Acquire from the environment Use `LLMClientIO.resource[F]` to load provider config from the environment, build the client, and release it on scope exit: (not compile-checked) ```scala import cats.effect.{IO, IOApp} import org.llm4s.effect.cats.LLMClientIO import org.llm4s.llmconnect.model.{Conversation, UserMessage} object MyApp extends IOApp.Simple { def run: IO[Unit] = LLMClientIO.resource[IO].use { client => for { completion <- client.complete(Conversation(Seq(UserMessage("What is 2 + 2?")))) _ <- IO.println(completion.content) } yield () } } ``` ### Wrap an existing client If you already hold an `LLMClient` (e.g. constructed manually): (not compile-checked) ```scala import org.llm4s.effect.cats.LLMClientIO val wrapped = LLMClientIO[IO](existingClient) ``` ### Streaming `streamComplete` returns an `fs2.Stream[F, StreamedChunk]` that delivers chunks incrementally. The blocking provider call runs on an interruptible blocking thread and feeds a bounded queue, so a slow consumer applies backpressure to the provider thread, and stopping early (`take`, fiber cancellation) interrupts the call. If the call fails mid-stream, chunks already received are emitted first and the stream then fails with `LLMException`: (not compile-checked) ```scala client .streamComplete(conversation) .evalMap(chunk => IO.print(chunk.content.getOrElse(""))) .compile .drain ``` ## AgentIO `AgentIO[F[_]]` wraps an `Agent`, shifting the blocking agent loop to the blocking pool. `client.agent(id)(configure)` builds the agent from `Agent.builder(id, client)` with `configure` applied, so tools, guardrails, handoffs and middleware are set there; a builder that does not build fails the effect with its error. (not compile-checked) ```scala for { agentIO <- client.agent("assistant")(_.withTools(myTools).withSystemPrompt("You are concise.")) result <- agentIO.run("Summarise this") _ <- IO.println(result.answer) } yield () ``` ### Multi-turn conversations (not compile-checked) ```scala for { s1 <- agentIO.run("What's the weather in Paris?") s2 <- agentIO.continueConversation(s1, "And London?") } yield s2 ``` ## Cancellation Cancellation is by thread interrupt, matching the llm4s core contract. `LLMClientIO.complete`, `AgentIO` and the streaming methods all run the provider call on an interruptible blocking thread, so cancelling the fiber (or a timeout) interrupts the call instead of waiting for it to finish. ## Error handling All `LLMError` values are raised as `LLMException` in the `F` error channel: (not compile-checked) ```scala import org.llm4s.effect.cats.LLMException client.complete(conversation).handleErrorWith { case e: LLMException => IO.println(s"LLM error: ${e.error.message}") case t => IO.raiseError(t) } ``` ## Serving an agent over HTTP [Serving an Agent over HTTP](http-reference-apps) is a tested http4s (Ember) app over `AgentIO[IO]`: `POST /chat` answers as JSON and `POST /chat/stream` streams the answer as server-sent events, with errors mapped to HTTP statuses and the run cancelled when the client disconnects. Run it with `sbt "examplesHttp/run --fake"`. The same guide has a Tapir variant: the two endpoints as Tapir definitions with typed error outputs, served by `tapir-http4s-server` over the same `AgentIO[IO]`, with generated OpenAPI documentation and a Swagger UI at `/docs`. ## Environment variables The variables llm4s reads, such as `OPENAI_API_KEY`, are listed in [Environment variables llm4s reads](../getting-started/configuration#environment-variables-llm4s-reads). The provider and model are chosen by a named section in `application.conf`; see [Named provider sections](../getting-started/configuration#named-provider-sections). ### Differences from `Agent` `AgentIO` is a deliberately thin wrapper over `Agent.start` and `AgentRun.await`. It exposes `run`, `continueConversation`, `recover` and `resume`, each taking a `RunConfig` and returning an `F[AgentResult]`. It does not expose a named `ThreadId` on `run` (each `run` starts a new thread, and `continueConversation` continues the thread of a previous result) or `history`; for those, build the `Agent` yourself and call `agent.start(...)` inside `IO.blocking`. Cancelling the fiber cancels the run and returns once its turn has ended, so the thread can be recovered at once with `recover`. Errors arrive as `LLMException` in the effect's error channel: a provider, tool or middleware failure is a `GraphError` (a provider error is `GraphError.NodeFailed(cause)`; an exception thrown by user code arrives as a `NodeFailed` carrying the original). A guardrail block, the step limit and a suspension for approval are not failures: they are the `AgentResult`'s `status`. Tool calls are not a failure of the effect either. When the model calls a tool with arguments that do not fit the tool's schema, the agent hands a structured error result (`{"error": ...}`) back to the model so it can correct itself; the run continues and only the step limit or a provider error ends it. If you need to see those results, read the `ToolMessage`s in `AgentResult.messages`. --- Source: https://llm4s.org/guide/zio # ZIO Integration The `llm4s-zio` module wraps the synchronous `LLMClient` and `Agent` APIs in [ZIO](https://zio.dev) effects, shifting blocking LLM calls to ZIO's blocking thread pool and surfacing `LLMError` directly in the ZIO error channel. ## Dependency (not compile-checked) ```scala // build.sbt libraryDependencies += "org.llm4s" %% "llm4s-zio" % "" ``` ## LLMClientZ `LLMClientZ` is the ZIO wrapper for `LLMClient`. ### Acquire via ZLayer Use `LLMClientZ.layer` to load provider config from the environment, build the client, and release it on scope exit: (not compile-checked) ```scala import org.llm4s.llmconnect.model.{Conversation, UserMessage} import org.llm4s.toolapi.ToolRegistry import org.llm4s.zio.LLMClientZ import zio.{ZIO, ZIOAppDefault} object MyApp extends ZIOAppDefault { def run: ZIO[Any, Any, Any] = (for { client <- ZIO.service[LLMClientZ] c <- client.complete(Conversation(Seq(UserMessage("What is 2 + 2?")))) _ <- ZIO.debug(c.content) } yield ()).provide(LLMClientZ.layer) } ``` ### Wrap an existing client (not compile-checked) ```scala import org.llm4s.zio.LLMClientZ val wrapped: LLMClientZ = LLMClientZ(existingClient) ``` ### Streaming `streamComplete` returns a `ZStream[Any, LLMError, StreamedChunk]` that delivers chunks incrementally. The blocking provider call runs on an interruptible blocking thread and feeds a bounded queue, so a slow consumer applies backpressure, and stopping early or interrupting the fiber interrupts the call. If the call fails mid-stream, chunks already received are emitted first and the stream then fails with the `LLMError`: (not compile-checked) ```scala client .streamComplete(conversation) .map(_.content.getOrElse("")) .runCollect .map(_.mkString) ``` ## AgentZ `AgentZ` wraps an `Agent`, shifting the blocking agent loop to ZIO's blocking pool. `client.agent(id)(configure)` builds the agent from `Agent.builder(id, client)` with `configure` applied, so tools, guardrails, handoffs and middleware are set there; a builder that does not build fails with its `LLMError`. `LLMError` is the native error type — no wrapping needed. (not compile-checked) ```scala for { agentZ <- client.agent("assistant")(_.withTools(myTools).withSystemPrompt("You are concise.")) result <- agentZ.run("Summarise this") _ <- ZIO.debug(result.answer.toString) } yield () ``` ### Multi-turn conversations (not compile-checked) ```scala for { s1 <- agentZ.run("What's the weather in Paris?") s2 <- agentZ.continueConversation(s1, "And London?") } yield s2 ``` ## Cancellation Cancellation is by thread interrupt, matching the llm4s core contract. `LLMClientZ.complete`, `AgentZ` and the streaming methods all run the provider call on an interruptible blocking thread, so cancelling the fiber (or a timeout) interrupts the call instead of waiting for it to finish. ## Serving an agent over HTTP [Serving an Agent over HTTP](http-reference-apps) includes a tested ZIO HTTP app over `AgentZ`: `POST /chat` answers as JSON and `POST /chat/stream` streams `AgentZ.stream` as server-sent events, with errors mapped to HTTP statuses and the run cancelled when the client disconnects. Run it with `sbt "examplesHttp/runMain org.llm4s.examples.http.ziohttp.ZioChatServerMain --fake"`. ## Error handling `LLMError` flows naturally in the ZIO error channel: (not compile-checked) ```scala client.complete(conversation).catchAll { err => ZIO.debug(s"LLM error: ${err.message}") *> ZIO.fail(err) } ``` ## Environment variables The variables llm4s reads, such as `OPENAI_API_KEY`, are listed in [Environment variables llm4s reads](../getting-started/configuration#environment-variables-llm4s-reads). The provider and model are chosen by a named section in `application.conf`; see [Named provider sections](../getting-started/configuration#named-provider-sections). ### Differences from `Agent` `AgentZ` is a deliberately thin wrapper over `Agent.start` and `AgentRun.await`. It exposes `run`, `continueConversation`, `recover` and `resume`, each taking a `RunConfig` and returning `ZIO[Any, LLMError, AgentResult]`. It does not expose a named `ThreadId` on `run` (each `run` starts a new thread, and `continueConversation` continues the thread of a previous result) or `history`; for those, build the `Agent` yourself and call `agent.start(...)` inside `ZIO.attemptBlocking`. Interrupting the fiber (or a timeout) cancels the run and returns once its turn has ended, so the thread can be recovered at once with `recover`. Errors arrive in the `LLMError` channel: a provider, tool or middleware failure is a `GraphError` (a provider error is `GraphError.NodeFailed(cause)`; an exception thrown by user code arrives as a `NodeFailed` carrying the original). A guardrail block, the step limit and a suspension for approval are not failures: they are the `AgentResult`'s `status`. Tool calls are not a failure of the effect either. When the model calls a tool with arguments that do not fit the tool's schema, the agent hands a structured error result (`{"error": ...}`) back to the model so it can correct itself; the run continues and only the step limit or a provider error ends it. If you need to see those results, read the `ToolMessage`s in `AgentResult.messages`. --- Source: https://llm4s.org/guide/caching # Caching Responses and Embeddings Avoid paying twice for the same work: an embedding cache for exact repeats, and a semantic cache for model responses. --- ## 1. What is available Both caches live in `llm4s-core`, package `org.llm4s.llmconnect.caching`. With their default backends both keep their entries in memory, inside the object you create, and lose them when the process ends. The completion cache always works that way; the embedding cache takes its storage as an `EmbeddingCache`, and one of your own (see [Your own storage](#your-own-storage)) can keep vectors in a shared or persistent store instead. | | Embedding cache | Completion (semantic) cache | |---|---|---| | Class | `CachedEmbeddingClient` with an `EmbeddingCache`, by default `InMemoryEmbeddingCache` | `CachingLLMClient`, which wraps any `LLMClient` | | A hit needs | the same text, the same model name and the same `InputPurpose` (document or query) | an embedding at least `similarityThreshold` similar, the same `CompletionOptions`, and an entry no older than the TTL | | Configured with | `InMemoryEmbeddingCache(maxSize = 10000, ttl = None)` | `CacheConfig.create(similarityThreshold, ttl, maxSize = 1000)` | | Eviction | least recently used, plus the optional TTL | least recently used, plus the TTL | | Seeing what happened | `cacheStats` | `TraceEvent.CacheHit` and `TraceEvent.CacheMiss` sent to your `Tracing` | There is no configuration-file key for either cache. You construct them in code, and the examples below do exactly that. ## 2. Choosing between them **The embedding cache is exact, so it is safe.** The same text embedded with the same model for the same purpose gives the same vector, so a cached vector is as good as a fresh one. Use it whenever you embed the same texts more than once: re-indexing documents, repeated queries in a RAG service, evaluation runs. **The completion cache is approximate, so it can be wrong.** It answers a new question with the stored answer to an earlier, *similar* one. That saves a model call, but: - It costs one embedding call on every `complete()`, hit or miss, because the prompt has to be embedded to be compared. The cache pays off only if a model call is worth more than that embedding call and your hit rate is high enough. - A hit can be a false hit: two prompts the embedding model considers close may need different answers. - A cached answer replaces a fresh sample. If you want a different answer each time, or an answer that depends on the current time, the user or fresh data, do not cache it. ## 3. Caching embeddings Wrap an `EmbeddingClient` in a `CachedEmbeddingClient` and call `embed` on the wrapper: (not compile-checked) ```scala import org.llm4s.llmconnect.caching.{ CachedEmbeddingClient, InMemoryEmbeddingCache } import org.llm4s.llmconnect.config.EmbeddingModelConfig import org.llm4s.llmconnect.model.EmbeddingRequest import scala.concurrent.duration._ val cache = new InMemoryEmbeddingCache[Seq[Double]](maxSize = 10000, ttl = Some(1.hour)) val cached = new CachedEmbeddingClient(base, cache) val model = EmbeddingModelConfig("text-embedding-3-small", 1536) cached.embed(EmbeddingRequest(Seq("hello", "world"), model)) // two misses, sent as one batched call cached.embed(EmbeddingRequest(Seq("hello", "again"), model)) // "hello" is served from the cache cached.cacheStats ``` Here `base` is the `EmbeddingClient` you would use without a cache. A request without a purpose embeds documents (`InputPurpose.Document`), which is what indexing wants. To cache repeated search queries, say so on the request, so that the query is embedded as one and cached apart from a document with the same text: (not compile-checked) ```scala import org.llm4s.llmconnect.caching.{ CachedEmbeddingClient, InMemoryEmbeddingCache } import org.llm4s.llmconnect.config.EmbeddingModelConfig import org.llm4s.llmconnect.model.{ EmbeddingRequest, InputPurpose } val queries = new CachedEmbeddingClient(base, new InMemoryEmbeddingCache[Seq[Double]]()) val model = EmbeddingModelConfig("text-embedding-3-small", 1536) queries.embed(EmbeddingRequest(Seq("what is llm4s?"), model, InputPurpose.Query)) // a miss queries.embed(EmbeddingRequest(Seq("what is llm4s?"), model, InputPurpose.Query)) // a hit queries.embed(EmbeddingRequest(Seq("what is llm4s?"), model)) // a miss: a document ``` The wrapper: - looks every input text up first, and sends only the texts it does not have to the base client, in **one** batched request, then returns the vectors in the original input order; - sends a text that is repeated inside one request once; - never caches a failure: an error from the base client is returned as it is, and the next call tries again; - is **not** an `EmbeddingClient`. It has the same `embed` method, but it cannot be passed where an `EmbeddingClient` is required, so call it directly. `cacheStats` returns `CacheStats(size, hits, misses, totalRequests, hitRatePercent)`. Every looked-up text counts, so the two calls above give 3 misses and 1 hit (25%), and a text repeated inside one request counts once per occurrence. With the default `InMemoryEmbeddingCache`, `clearCache()` empties the cache and resets the statistics. With a custom `EmbeddingCache`, it delegates to that backend's `clear()` implementation; the trait default does nothing, and a backend may clear entries without resetting statistics. ### The in-memory cache `InMemoryEmbeddingCache(maxSize = 10000, ttl = None)`: - **Size.** At more than `maxSize` entries the least recently used one is evicted. Reading an entry counts as using it. - **TTL.** With `ttl = Some(d)` an entry is expired once it is *older* than `d`. An entry exactly `d` old is still valid. Expiry is lazy: an expired entry is removed when it is read, and that read counts as a miss. - **Threads.** It is safe to share between threads. ### What the key contains The key function is given three things: the text, the model name and the request's `InputPurpose`. Some providers (Voyage, Jina and Cohere among them) embed a query and a document with the same text differently, so the purpose has to be part of the key. The default, `CacheKeyGenerator.embeddingKey`, is the SHA-256 of those three as 64 hex characters. Each part is prefixed with its length before hashing, so the encoding is unambiguous: no text or model name, whatever characters it contains, can produce another request's key. The text `a:b` with model `c` and the text `a` with model `b:c` have different keys, and so do a query for model `m` and a document for a model named `m#query`. The key holds the model *name* only. It does not include the embedding dimension or the provider, so one cache shared by clients that use the same model name for different things would mix their vectors. If that matters to you, or you want to keep tenants apart in one cache, pass your own key function as the third argument. `CacheKeyGenerator.sha256` takes any number of parts and encodes them the same unambiguous way, so a tenant can simply be one more part: (not compile-checked) ```scala import org.llm4s.llmconnect.caching.{ CacheKeyGenerator, CachedEmbeddingClient, InMemoryEmbeddingCache } import org.llm4s.llmconnect.config.EmbeddingModelConfig import org.llm4s.llmconnect.model.{ EmbeddingRequest, InputPurpose } // The tenant is one more part of the key; sha256 keeps every part apart from the others. def tenantKey(tenant: String)(text: String, model: String, purpose: InputPurpose): String = CacheKeyGenerator.sha256(tenant, model, purpose.toString, text) val cache = new InMemoryEmbeddingCache[Seq[Double]]() val cached = new CachedEmbeddingClient(base, cache, tenantKey("tenant-a")) cached.embed(EmbeddingRequest(Seq("hello"), EmbeddingModelConfig("text-embedding-3-small", 1536))) ``` ### Your own storage `EmbeddingCache[A]` is a small trait: `get`, `put`, `stats`, and an optional `clear`. Implement it to keep vectors somewhere else (a shared map, Redis, a database). Expiry and eviction are then your backend's job: (not compile-checked) ```scala import org.llm4s.llmconnect.caching.{ CacheStats, EmbeddingCache } import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger /** A backend of your own: any store that can get and put a vector by key. */ class MapEmbeddingCache extends EmbeddingCache[Seq[Double]] { private val store = new ConcurrentHashMap[String, Seq[Double]]() private val hits = new AtomicInteger(0) private val misses = new AtomicInteger(0) def get(key: String): Option[Seq[Double]] = { val found = Option(store.get(key)) if (found.isDefined) hits.incrementAndGet() else misses.incrementAndGet() found } def put(key: String, embedding: Seq[Double]): Unit = { store.put(key, embedding); () } override def clear(): Unit = store.clear() def stats(): CacheStats = { val (h, m) = (hits.get().toLong, misses.get().toLong) CacheStats(store.size(), h, m, h + m, if (h + m == 0) 0.0 else 100.0 * h / (h + m)) } } ``` ### Limits - **No request coalescing.** The cache is checked and then filled in separate steps. If several threads miss the same text at the same moment, each one calls the base client; the last write wins. Four concurrent requests for the same new text made four provider calls. ## 4. Caching model responses ### `CacheConfig` Build a `CacheConfig` with `CacheConfig.create`. It returns a `Result`, because the values are checked: (not compile-checked) ```scala import org.llm4s.llmconnect.caching.CacheConfig import scala.concurrent.duration._ CacheConfig.create( similarityThreshold = 0.95, // cosine similarity, 0.0 to 1.0 ttl = 5.minutes, // an older entry is ignored maxSize = 100 // least recently used entry is evicted beyond this ) ``` | Field | Type | Meaning | |---|---|---| | `similarityThreshold` | `Double`, 0.0 to 1.0 inclusive (`NaN` is rejected) | the minimum cosine similarity between the new prompt's embedding and a stored one for a hit | | `ttl` | `FiniteDuration`, positive | an entry older than this is ignored | | `maxSize` | `Int`, positive, default `1000` | the number of entries kept; beyond it the least recently used is evicted | `create` reports every violated constraint at once, in the message of the `ValidationError` it returns. There is no public constructor: `CacheConfig(...)` without `create` does not compile. ### The caching client `CachingLLMClient` takes the client to wrap, an `EmbeddingClient` and the embedding model it should use, the `CacheConfig`, and a `Tracing`. This is the shape the [`SemanticCachingSample`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cache/SemanticCachingSample.scala) uses: (not compile-checked) ```scala import org.llm4s.llmconnect.caching.CachingLLMClient import org.llm4s.llmconnect.config.EmbeddingModelConfig new CachingLLMClient( baseClient = baseClient, embeddingClient = embeddingClient, embeddingModel = EmbeddingModelConfig("text-embedding-3-small", 1536), config = cacheConfig, tracing = tracing ) ``` It is an `LLMClient`, so it goes anywhere the client you wrap would go. The sample shows it end to end against a real provider: it needs an OpenAI key (see [running the samples](../getting-started/configuration#running-the-samples)), then ``` sbt "samples/runMain org.llm4s.samples.cache.SemanticCachingSample" ``` ### How a lookup works On each `complete(conversation, options)`: 1. The cache builds a prompt from the **user and system messages** only, as lines of `role: content`, and embeds it with your `EmbeddingClient`. That is one embedding call, and the prompt text goes to your embedding provider. 2. It compares that embedding with every stored one by cosine similarity. A stored entry is a candidate when its similarity is **at or above** `similarityThreshold`. 3. A candidate is a hit when its `CompletionOptions` are **equal** to the request's (temperature, tools and the rest) and it is **within the TTL**. If several qualify, the most similar wins. 4. On a hit the stored `Completion` is returned and the entry counts as recently used. It is the same object each time, so do not mutate it. 5. On a miss the base client is called, a successful result is stored, and it is returned. ### What is, and is not, compared Only user and system messages are embedded. Assistant messages and tool messages are left out on purpose, so that tool arguments and tool results never reach the embedding provider. The consequence is that **two conversations that differ only in their assistant or tool turns look identical to the cache**: ```text [user: "what is 2+2", assistant: "thinking", tool: "4"] [user: "what is 2+2", assistant: "other", tool: "5"] -> served the first one's answer ``` For a multi-turn agent run this can return an answer that does not match the tool results in front of it. Cache the single-shot, self-contained calls, not the steps of an agent loop. ### Choosing the threshold A high threshold gives few false hits and few hits; a low one the opposite. The right value depends on the embedding model and on your questions, so measure it on real queries. The sample uses `0.95`. Do not use `1.0` to mean "exact match". Similarity is computed in floating point, and even an identical prompt can come out a hair below `1.0`: with an embedding of `(0.3, 0.3, 0.3)` the same prompt asked twice was a miss at a threshold of `1.0`. If you want exact matching, use the exact embedding cache above, or a threshold just under `1.0`. ### TTL, size and memory - An entry older than `ttl` is skipped (one exactly `ttl` old is still used, as in the embedding cache), but it is **not removed**. It stays in memory until the least-recently-used rule evicts it, so `maxSize`, not `ttl`, is what bounds memory. - The lookup compares the new embedding with every stored entry, so its cost grows with `maxSize`. Keep it modest. - Use the `clock` constructor parameter to control time in tests. It is only consulted for the TTL: (not compile-checked) ```scala import org.llm4s.llmconnect.caching.CachingLLMClient import org.llm4s.llmconnect.config.EmbeddingModelConfig // The clock only decides whether an entry is still within its TTL. Tests pass a fixed, movable one. new CachingLLMClient( baseClient, embeddingClient, EmbeddingModelConfig("text-embedding-3-small", 1536), cacheConfig, tracing, clock ) ``` ### What is never cached - **Streaming.** `streamComplete` goes straight to the base client and neither reads nor fills the cache. - **Failures.** If the base client returns an error, the error is returned and nothing is stored. - **When the embedding call fails.** If embedding the prompt fails or returns nothing, the request goes to the base client uncached. The only trace of it is a warning in the log: no trace event is emitted, so a cache whose embedding service is down looks like a cache that always misses. ### Sharing, privacy and threads - The entries belong to that `CachingLLMClient` instance. Everyone who uses the instance shares them; nothing is keyed by user or tenant. Do not share one instance between users whose answers must not mix, and give each its own. - The entries live in memory, and a restart empties the cache. - It is safe to call from several threads. The model calls themselves are not serialised, and like the embedding cache it does not coalesce: several threads asking the same new question at once all call the model (four concurrent identical requests made four model calls). - The completion cache has no way to inspect or clear it. To start again, create a new instance. ## 5. Seeing what the cache did The completion cache reports each decision to the `Tracing` you give it. A hit sends `TraceEvent.CacheHit(similarity, threshold, timestamp)`; a miss sends `TraceEvent.CacheMiss(reason, timestamp)`, where the reason says why: | `reason` | `reason.value` | Meaning | |---|---|---| | `LowSimilarity` | `low_similarity` | no stored entry reached the threshold (this includes an empty cache) | | `TtlExpired` | `ttl_expired` | entries reached the threshold, and some had the same options, but none was within the TTL | | `OptionsMismatch` | `options_mismatch` | entries reached the threshold, but none had the same `CompletionOptions` | A `Tracing` that records them, and a way to print one: (not compile-checked) ```scala import org.llm4s.llmconnect.model.{ Completion, TokenUsage } import org.llm4s.trace.{ TraceEvent, Tracing } import org.llm4s.types.Result import scala.collection.mutable.ListBuffer def collectingTracing(): (Tracing, () => List[TraceEvent]) = { val events = ListBuffer.empty[TraceEvent] val tracing = new Tracing { override def traceEvent(event: TraceEvent): Result[Unit] = { events.synchronized(events += event); Right(()) } override def traceToolCall(toolName: String, input: String, output: String): Result[Unit] = Right(()) override def traceError(error: Throwable, context: String): Result[Unit] = Right(()) override def traceCompletion(completion: Completion, model: String): Result[Unit] = Right(()) override def traceTokenUsage(usage: TokenUsage, model: String, operation: String): Result[Unit] = Right(()) } (tracing, () => events.synchronized(events.toList)) } def describe(event: TraceEvent): String = event match { case TraceEvent.CacheHit(similarity, threshold, _) => f"hit, similarity $similarity%.3f >= $threshold%.3f" case TraceEvent.CacheMiss(reason, _) => s"miss: ${reason.value}" case other => other.eventType } ``` Asking the same question twice then gives `miss: low_similarity` followed by `hit, similarity 1.000 >= 0.900` (for a threshold of `0.900`). The bundled tracing backends already handle both events: `ConsoleTracing` prints `CACHE HIT` (with the similarity and threshold) and `CACHE MISS` (with the reason), the Langfuse backend records a `Cache Hit` or `Cache Miss` span with the same values, and the OpenTelemetry backend records a `Cache Hit` or `Cache Miss` event with `similarity`, `threshold` and `reason` attributes. The embedding cache has no trace events; read its `cacheStats` instead (section 3). ## 6. Where caching does not belong - Calls that should return a fresh, varied or personalised answer. - Calls with side effects, such as the steps of an agent that runs tools. - Anything where a wrong answer from a near-miss prompt costs more than the model call saved. - Several users on one `CachingLLMClient`, when their answers must stay separate. ## See also - [Error Handling](error-handling): the `Result` and `LLMError` types every call here returns. - [Vector Store](vector-store): where the vectors of an indexed corpus live, which a cache does not replace. - [`SemanticCachingSample`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cache/SemanticCachingSample.scala): the cache against a real provider. --- Source: https://llm4s.org/guide/json-libraries # Using LLM4S with circe, play-json and zio-json LLM4S reads and writes JSON with uPickle and its `ujson` AST. This page shows how an application that already uses circe, play-json or zio-json passes its own types in and gets them back, without writing a second set of codecs. --- ## Where ujson shows in the API Three places are where your JSON library meets LLM4S: | Where | What LLM4S takes or returns | |---|---| | `LLMClient.completeStructured[A]` | an implicit `upickle.default.Reader[A]` for the type you want back | | Tool handlers and results | `SafeParameterExtractor.params` is a `ujson.Value`; `ToolFunction.execute` takes and returns a `ujson.Value`; a tool's result type needs a uPickle `ReadWriter` (use `ujson.Value` itself to return a document you built) | | Agent interrupts and events | `Agent.streamResume` takes `Map[InterruptId, ujson.Value]`; `AgentResult.edit` takes the edited `ujson.Value`; the `ToolCallStarted` event carries its `arguments` as a `ujson.Value` | So there are two jobs: turn a `ujson.Value` into your library's document (and back), and turn your library's decoder into the `Reader` that `completeStructured` asks for. The examples use these types: a nested object, an `Option`, a collection and the three numeric kinds. (not compile-checked) ```scala final case class Address(city: String, zip: Option[String]) final case class Order(id: Int, total: Double, address: Address, tags: List[String], note: Option[String]) val addressSchema: ObjectSchema[Address] = Schema .`object`[Address]("Where to deliver") .withRequiredField("city", Schema.string("The city")) .withProperty(PropertyDefinition("zip", Schema.string("The postal code"), required = false)) val orderSchema: ObjectSchema[Order] = Schema .`object`[Order]("An order") .withRequiredField("id", Schema.integer("The order number")) .withRequiredField("total", Schema.number("The total")) .withRequiredField("address", addressSchema) .withRequiredField("tags", Schema.array("Labels", Schema.string("A label"))) .withProperty(PropertyDefinition("note", Schema.string("A note"), required = false)) ``` ## What to add Nothing here goes into LLM4S itself; add the library you use to your own build. uPickle's `ujson-circe` is the bridge for circe. **There is no Scala 3 build of `ujson-play`** (on Maven Central it exists for Scala 2.11 to 2.13 only, checked on 2026-10-08), and zio-json has no bridge at all, so those two cross as text. The versions below are the ones this page was run with. ```sbt // circe: ujson-circe is uPickle's own bridge, built for Scala 3. Keep its version equal to the ujson that LLM4S uses. libraryDependencies ++= Seq( "io.circe" %% "circe-core" % "0.14.17", "com.lihaoyi" %% "ujson-circe" % "4.4.3" ) // play-json (no bridge needed: it crosses as text) libraryDependencies += "org.playframework" %% "play-json" % "3.0.6" // zio-json (no bridge needed: it crosses as text) libraryDependencies += "dev.zio" %% "zio-json" % "1.0.0" ``` **zio-json 1.1.0 needs a Scala 3.9 compiler.** It depends on `scala3-library` 3.9.0, and a project compiled with Scala 3.7.1 (the version LLM4S is built with) cannot read that library: the compiler stops with "Forward incompatible TASTy file ... produced by Scala 3.9.0". Use 1.0.0 (built against Scala 3.3) unless your project is on Scala 3.9 or later. zio-json 1.0.0 also needs zio 2.1.26 or newer, which sbt resolves for you. ## circe ### Converting a document `ujson.transform` visits a `ujson.Value` with circe's visitor and builds a `Json`, and `CirceJson.transform` goes the other way. Nothing is rendered to text on the way. (not compile-checked) ```scala import io.circe.Json import ujson.circe.CirceJson def toCirce(value: ujson.Value): Json = ujson.transform(value, CirceJson) def fromCirce(json: Json): ujson.Value = CirceJson.transform(json, ujson.Value) ``` ### Using your Decoder with completeStructured `completeStructured` wants a uPickle `Reader[A]`. A `Reader` can be built from the `ujson.Value` reader, so any circe `Decoder` becomes one. Here the codecs come from circe's own derivation: (not compile-checked) ```scala import io.circe.{ Decoder, Encoder } import io.circe.syntax._ given Decoder[Address] = Decoder.derived given Decoder[Order] = Decoder.derived given Encoder[Address] = Encoder.AsObject.derived given Encoder[Order] = Encoder.AsObject.derived ``` (not compile-checked) ```scala import upickle.default.{ reader, Reader } def circeReader[A](using decoder: Decoder[A]): Reader[A] = reader[ujson.Value].map { value => decoder.decodeJson(toCirce(value)).fold(error => throw upickle.core.Abort(error.getMessage), identity) } ``` Then pass it where the `Reader` goes: (not compile-checked) ```scala def orderViaCirce(client: LLMClient, conversation: Conversation): Result[Order] = client.completeStructured[Order](conversation, orderSchema)(using circeReader[Order]) ``` `Option` fields can be `null` or absent, and nested objects decode as circe decodes them. The schema you pass to `completeStructured` is still an LLM4S `ObjectSchema` (circe does not derive it; deriving one from a case class is tracked in [#1472](https://github.com/llm4s/llm4s/issues/1472)), and it reaches the provider exactly as with a uPickle `Reader`. **When decoding fails.** The `Reader` throws `upickle.core.Abort` with the decoder's message. `completeStructured` catches anything thrown while reading and returns `Left(ValidationError("structured_output", "Response does not match expected schema: ..."))`, the same error the uPickle path gives, so your error handling does not change (see [Error Handling](error-handling)). A reply that is JSON `null` is refused and never comes back as `Right(null)`. ### Tools that use circe A tool handler receives its arguments as `extractor.params`, a `ujson.Value`. Decode it with circe, and return a `ujson.Value` built from your result's encoder: (not compile-checked) ```scala final case class AddArgs(a: Int, b: Int) final case class AddResult(sum: Long) given Decoder[AddArgs] = Decoder.derived given Encoder[AddResult] = Encoder.AsObject.derived val addSchema = Schema .`object`[Map[String, Any]]("Two integers") .withRequiredField("a", Schema.integer("The first")) .withRequiredField("b", Schema.integer("The second")) val addTool = ToolBuilder[Map[String, Any], ujson.Value]("add", "Adds two integers", addSchema) .withHandler { extractor => toCirce(extractor.params).as[AddArgs] match { case Right(args) => Right(fromCirce(AddResult(args.a.toLong + args.b).asJson)) case Left(error) => Left(error.getMessage) } } .buildSafe() ``` A value that does not fit the case class (`"a": "two"`, or a missing field) is returned to the model as a tool error, not thrown. See [Built-in Tools](builtin-tools) for how tool errors reach the model. ### Answers and edits to an interrupted run Wherever LLM4S takes a `ujson.Value` (the `answers` of `Agent.streamResume`, the `arguments` of `AgentResult.edit`), build it from a circe `Json` with the same conversion: (not compile-checked) ```scala val answer: ujson.Value = fromCirce(Json.obj("approved" -> Json.fromBoolean(true))) ``` ## play-json play-json reads and writes text, so the document crosses as a string: `value.render()` is compact JSON, and `PlayJson.parse` reads it. This costs one copy of the document each way; it is fine for tool arguments and structured answers, which are small. (not compile-checked) ```scala import play.api.libs.json.{ JsError, JsSuccess, Json as PlayJson, JsValue, Reads } def toPlay(value: ujson.Value): JsValue = PlayJson.parse(value.render()) def fromPlay(json: JsValue): ujson.Value = ujson.read(PlayJson.stringify(json)) def playReader[A](using reads: Reads[A]): Reader[A] = reader[ujson.Value].map { value => reads.reads(toPlay(value)) match { case JsSuccess(decoded, _) => decoded case JsError(errors) => throw upickle.core.Abort(errors.toString) } } given Reads[Address] = PlayJson.reads[Address] given Reads[Order] = PlayJson.reads[Order] ``` (not compile-checked) ```scala def orderViaPlay(client: LLMClient, conversation: Conversation): Result[Order] = client.completeStructured[Order](conversation, orderSchema)(using playReader[Order]) ``` A document that does not fit the `Reads` gives the same `ValidationError("structured_output", ...)` as above. ## zio-json zio-json decodes straight from text, so the `Reader` renders the document once and decodes it. To hand a zio-json value to LLM4S, encode it to text and read it back as a `ujson.Value`. Note that zio-json leaves a `None` field out of the document it writes, where circe writes `null`; every one of the three libraries reads both forms back as `None`, so a model may answer either way. (not compile-checked) ```scala import zio.json.{ DeriveJsonDecoder, DeriveJsonEncoder, JsonDecoder, JsonEncoder } import zio.json.{ DecoderOps, EncoderOps } given JsonDecoder[Address] = DeriveJsonDecoder.gen[Address] given JsonDecoder[Order] = DeriveJsonDecoder.gen[Order] given JsonEncoder[Address] = DeriveJsonEncoder.gen[Address] given JsonEncoder[Order] = DeriveJsonEncoder.gen[Order] def zioReader[A](using decoder: JsonDecoder[A]): Reader[A] = reader[ujson.Value].map { value => value.render().fromJson[A].fold(message => throw upickle.core.Abort(message), identity) } def fromZio[A](value: A)(using encoder: JsonEncoder[A]): ujson.Value = ujson.read(value.toJson) ``` (not compile-checked) ```scala def orderViaZio(client: LLMClient, conversation: Conversation): Result[Order] = client.completeStructured[Order](conversation, orderSchema)(using zioReader[Order]) ``` ## Numbers A `ujson` number is a double. Doubles and ordinary integers (up to 2^53) round trip unchanged, but **a whole number above 2^53 is rounded to the nearest double as soon as it is parsed into the `ujson` AST**. That is `ujson`'s own parse, so it is not a property of circe, play-json or zio-json: (not compile-checked) ```scala val big = 9007199254740993L // 2^53 + 1: the first integer a double cannot hold val parsed: Long = ujson.read(s"""{"id":$big}""")("id").num.toLong ``` The first integer a double cannot hold, 2^53 + 1 (9007199254740993), comes back as 9007199254740992. What this means for structured output depends on the reader: - **uPickle readers are exact.** `completeStructured` reads `A` from the reply's text, so a uPickle `Long` field receives 9007199254740993 unchanged. - **The bridges on this page round.** `circeReader`, `playReader` and `zioReader` start from `reader[ujson.Value]`, so a circe `Decoder[Long]` (and its play-json and zio-json counterparts) reads the already-rounded value. - **Tool arguments round too.** A tool call's arguments reach a handler as a `ujson.Value`. `SafeParameterExtractor.getInt` refuses any number outside the `Int` range, so a typed integer parameter is never silently wrong; a large number read from the raw JSON, though, has already been rounded. So if a model has to return or receive large identifiers (snowflake ids, database keys above 9 quadrillion) through a bridge or a tool, make them strings in the schema and the case class, and convert in your own code. See [#1597](https://github.com/llm4s/llm4s/issues/1597). ## What this page does not cover - **A bridge module.** No `llm4s-circe` module is published. The conversion is two lines and the `Reader` is five, as shown above, so a module would add a dependency and a maintenance promise for little. Its main value would be one place for the error mapping, and only circe has a Scala 3 bridge, so a module could not treat the three libraries alike. If you would use one, say so on [#1458](https://github.com/llm4s/llm4s/issues/1458). - **Deriving tool or response schemas** from your case classes. The schemas are still built with LLM4S's `Schema` (issue [#1472](https://github.com/llm4s/llm4s/issues/1472)). - **Streaming events.** The event fields that are `ujson.Value`s, such as the `arguments` of `ToolCallStarted`, convert the same way. ## How this page is checked Every Scala block on this page is a snippet of `JsonLibrariesGuideSpec` in the `llm4s-samples` tests, which compiles and runs them against the real libraries at the versions in the `sbt` block above, using a scripted client in place of a model. The same spec fails if a block here differs from its snippet or a version here differs from the build's. --- Source: https://llm4s.org/guide/rag-evaluation # RAG Evaluation & Benchmarking Systematically measure and improve your RAG pipeline quality using RAGAS metrics and the benchmarking harness. --- ## Overview Building a RAG (Retrieval-Augmented Generation) system is just the beginning. The real challenge is knowing whether your retrieval is actually helping your LLM produce better answers. LLM4S provides two complementary tools: 1. **RAGAS Evaluator** - Measure retrieval and generation quality with industry-standard metrics 2. **Benchmarking Harness** - Systematically compare different RAG configurations **Key Benefits:** - Data-driven optimization of chunking strategies - Objective comparison of fusion methods (RRF vs weighted vs vector-only) - Embedding provider evaluation - Reproducible experiments with JSON reports --- ## RAGAS Metrics Explained [RAGAS](https://docs.ragas.io/) (Retrieval Augmented Generation Assessment) provides four core metrics: ### Faithfulness (0.0 - 1.0) Measures whether the generated answer is grounded in the retrieved context. - **1.0**: Answer fully supported by context - no hallucination - **0.0**: Answer contains claims not in the retrieved documents ``` Question: "What year was Scala released?" Context: "Scala was first released in 2004." Answer: "Scala was released in 2004." Faithfulness: 1.0 (fully grounded) ``` ### Answer Relevancy (0.0 - 1.0) Measures how relevant the answer is to the question asked. - **1.0**: Answer directly addresses the question - **0.0**: Answer is completely off-topic ``` Question: "What is Scala?" Answer: "Scala is a programming language." Relevancy: ~0.85 (relevant but could be more complete) ``` ### Context Precision (0.0 - 1.0) Measures whether the retrieved chunks are relevant to answering the question. - **1.0**: All retrieved chunks contribute to the answer - **0.0**: None of the retrieved chunks are relevant *High precision = no wasted context tokens* ### Context Recall (0.0 - 1.0) Measures whether all information needed to answer was retrieved. - **1.0**: All necessary information was retrieved - **0.0**: Critical information was missed *High recall = comprehensive retrieval* ### Overall RAGAS Score The harmonic mean of all metrics, providing a single quality score. This is what the benchmarking harness uses for ranking. --- ## Quick Start: Evaluate Your RAG ### Basic Evaluation (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.rag.evaluation._ import org.llm4s.llmconnect.LLMConnect // Initialize val llm = Llm4sConfig .provider() .flatMap(LLMConnect.getClient) .fold(_ => ???, identity) val evaluator = new RAGASEvaluator(llm) // Create an evaluation sample val sample = EvalSample( question = "What is Scala?", answer = "Scala is a programming language that runs on the JVM.", contexts = Seq("Scala is a functional and object-oriented programming language."), groundTruth = Some("Scala is a programming language.") ) // Evaluate val result = evaluator.evaluate(sample) result.foreach { metrics => println(s"Faithfulness: ${metrics.faithfulness}") println(s"Answer Relevancy: ${metrics.answerRelevancy}") println(s"Context Precision: ${metrics.contextPrecision}") println(s"Context Recall: ${metrics.contextRecall}") println(s"Overall RAGAS: ${metrics.ragasScore}") } ``` ### Batch Evaluation (not compile-checked) ```scala val samples = Seq(sample1, sample2, sample3) val results = evaluator.evaluateBatch(samples) results.foreach { allMetrics => val avgRagas = allMetrics.map(_.ragasScore).sum / allMetrics.size println(s"Average RAGAS Score: $avgRagas") } ``` --- ## Benchmarking Harness The benchmarking harness lets you systematically compare different RAG configurations to find what works best for your data. ### Running Pre-Built Benchmarks ```bash # Download test dataset ./scripts/download-datasets.sh ragbench # Configure providers (see the note below), then: export OPENAI_API_KEY=sk-... export LLM4S_PROVIDER=openai-main # bound by the samples' application.conf export EMBEDDING_MODEL=openai/text-embedding-3-small # bound by llm4s-core's reference.conf # Run fusion strategy comparison sbt "samples/runMain org.llm4s.samples.rag.BenchmarkExample --suite fusion" # Run chunking strategy comparison sbt "samples/runMain org.llm4s.samples.rag.BenchmarkExample --suite chunking" # Run embedding provider comparison sbt "samples/runMain org.llm4s.samples.rag.BenchmarkExample --suite embedding" # Quick test (5 samples) sbt "samples/runMain org.llm4s.samples.rag.BenchmarkExample --quick" ``` The samples read `modules/samples/src/main/resources/application.conf`, whose only provider section is `ollama-local`, so add an OpenAI chat section to `modules/samples/src/main/resources/application.local.conf` (not committed; included by `application.conf`). Neither it nor OpenAI embeddings need an `apiKey` line: both use `OPENAI_API_KEY`, which `llm4s-openai` binds to OpenAI's shared key. ```hocon llm4s { providers { openai-main { provider = "openai" model = "gpt-4o" } } } ``` See [Running the samples](../getting-started/configuration#running-the-samples). ### Understanding the Output ``` ====================================================================== BENCHMARK RESULTS: fusion-comparison Compare different search fusion strategies ====================================================================== Duration: 327.83s Experiments: 6 (6 passed, 0 failed) RANKINGS BY RAGAS SCORE ---------------------------------------------------------------------- Rank Config RAGAS Faith Relevancy ---------------------------------------------------------------------- 1 rrf-20 0.911 1.000 0.844 2 rrf-60 0.894 1.000 0.841 3 weighted-70v 0.893 1.000 0.841 4 weighted-50v 0.892 1.000 0.836 5 vector-only 0.884 0.960 0.845 6 keyword-only 0.809 N/A 0.809 ====================================================================== WINNER: rrf-20 (RAGAS: 0.911) ====================================================================== ``` --- ## What Each Benchmark Tests ### Fusion Strategy Benchmark Compares how to combine vector (semantic) and keyword (BM25) search results. | Strategy | Description | When to Use | |----------|-------------|-------------| | `rrf-20` | Reciprocal Rank Fusion, k=20 | Best overall performance | | `rrf-60` | Reciprocal Rank Fusion, k=60 | More conservative ranking | | `weighted-70v` | 70% vector + 30% keyword | Semantic-focused | | `weighted-50v` | 50% vector + 50% keyword | Balanced | | `vector-only` | Pure embedding similarity | Conceptual queries | | `keyword-only` | Pure BM25 | Exact term matching | **Typical Finding:** Hybrid search (RRF) outperforms single-method search by 3-5%. ### Chunking Strategy Benchmark Compares different document splitting approaches. | Strategy | Target Size | Best For | |----------|-------------|----------| | `sentence-small` | 400 chars | Focused factual queries | | `sentence-default` | 800 chars | General use | | `sentence-large` | 1200 chars | Complex reasoning | | `markdown-default` | 800 chars | Structured documents | | `simple-default` | 800 chars | Baseline comparison | **Typical Finding:** Sentence-aware chunking with smaller chunks improves precision. ### Embedding Provider Benchmark Compares embedding quality across providers. | Provider | Model | Dimensions | Notes | |----------|-------|------------|-------| | OpenAI | text-embedding-3-small | 1536 | Good balance of cost/quality | | OpenAI | text-embedding-3-large | 3072 | Highest quality | | Voyage | voyage-3 | 1024 | Optimized for retrieval | | Ollama | nomic-embed-text | 768 | Free, local | --- ## Programmatic Benchmarking ### Custom Benchmark Suite (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.rag.benchmark._ import org.llm4s.llmconnect.{ EmbeddingClient, LLMConnect } // Initialize clients val llm = Llm4sConfig .provider() .flatMap(LLMConnect.getClient) .fold(_ => ???, identity) val embed = Llm4sConfig .embeddings() .flatMap { case (provider, cfg) => EmbeddingClient.from(provider, cfg) } .fold(_ => ???, identity) val runner = new BenchmarkRunner(llm, embed) // Define custom experiments val experiments = Seq( RAGExperimentConfig( name = "my-config-1", chunkingStrategy = ChunkerFactory.Strategy.Sentence, fusionStrategy = FusionStrategy.RRF(30), topK = 5 ), RAGExperimentConfig( name = "my-config-2", chunkingStrategy = ChunkerFactory.Strategy.Markdown, fusionStrategy = FusionStrategy.WeightedScore(0.8, 0.2), topK = 10 ) ) // Create suite val suite = BenchmarkSuite( name = "my-comparison", description = "Custom experiment comparison", experiments = experiments, datasetPath = "path/to/dataset.jsonl" ) // Run and report val results = runner.runSuite(suite) println(BenchmarkReport.console(results)) BenchmarkReport.save(results, "data/results/my-comparison.json") // returns Result[Unit] ``` ### Custom Dataset Format Create a JSONL file with your own test data: ```jsonl {"question": "What is Scala?", "documents": ["Scala is a programming language..."], "answer": "Scala is a JVM language."} {"question": "How does pattern matching work?", "documents": ["Pattern matching in Scala..."], "answer": "Pattern matching uses case expressions."} ``` --- ## Improving Your RAG with Benchmarks ### Optimization Workflow ``` 1. BASELINE Run benchmark with default settings Record baseline RAGAS score 2. HYPOTHESIS "Smaller chunks might improve precision" 3. EXPERIMENT Run chunking benchmark Compare sentence-small vs sentence-default 4. ANALYZE Did RAGAS score improve? Which sub-metrics changed? 5. ITERATE Apply winning config Test new hypothesis ``` ### Common Optimization Patterns #### Low Faithfulness Score **Problem:** LLM generating claims not in context. **Solutions:** - Increase `topK` to retrieve more context - Switch to larger chunk sizes - Add reranking to improve context quality - Use more explicit RAG prompts #### Low Context Precision **Problem:** Retrieving irrelevant chunks. **Solutions:** - Try smaller chunk sizes for tighter matches - Use hybrid search (RRF) to combine semantic + keyword - Add metadata filtering - Tune embedding model #### Low Context Recall **Problem:** Missing relevant information. **Solutions:** - Increase `topK` retrieval count - Use larger chunk overlap - Add query expansion - Try different embedding models #### Low Answer Relevancy **Problem:** Answers don't address the question directly. **Solutions:** - Improve RAG prompt template - Use query rewriting - Check if context contains the answer --- ## Interpreting Results ### What's a Good RAGAS Score? | Score Range | Interpretation | |-------------|----------------| | 0.90+ | Excellent - production ready | | 0.80-0.90 | Good - suitable for most use cases | | 0.70-0.80 | Acceptable - room for improvement | | 0.60-0.70 | Fair - needs optimization | | <0.60 | Poor - significant issues | ### Comparing Configurations When comparing configurations: 1. **Statistical Significance**: Small differences (<0.02) may be noise 2. **Trade-offs**: Higher precision often means lower recall 3. **Domain Specificity**: Best config varies by data type 4. **Cost Considerations**: More retrieval = more embedding calls --- ## Benchmark Results Reference Based on actual benchmarks with RAGBench dataset: ### Fusion Strategy Results | Config | RAGAS | Faithfulness | Relevancy | |--------|-------|--------------|-----------| | rrf-20 | 0.911 | 1.000 | 0.844 | | rrf-60 | 0.894 | 1.000 | 0.841 | | weighted-70v | 0.893 | 1.000 | 0.841 | | weighted-50v | 0.892 | 1.000 | 0.836 | | vector-only | 0.884 | 0.960 | 0.845 | | keyword-only | 0.809 | N/A | 0.809 | **Key Insight:** Hybrid search (RRF) outperforms pure vector or keyword search. ### Chunking Strategy Results | Config | RAGAS | Faithfulness | Relevancy | |--------|-------|--------------|-----------| | sentence-small | 0.910 | 1.000 | 0.840 | | sentence-large | 0.893 | 1.000 | 0.838 | | sentence-default | 0.892 | 1.000 | 0.834 | | markdown-default | 0.885 | 0.960 | 0.847 | | simple-default | 0.882 | 0.960 | 0.833 | **Key Insight:** Smaller, sentence-aware chunks perform best for focused queries. --- ## Best Practices ### 1. Start with Baselines Always run benchmarks with default settings first to establish a baseline. ### 2. Change One Variable at a Time When optimizing, change only one configuration at a time to understand impact. ### 3. Use Representative Data Your benchmark dataset should reflect real-world queries your system will handle. ### 4. Consider the Full Picture Don't optimize for one metric at the expense of others. Balance is key. ### 5. Monitor in Production Benchmark scores are offline metrics. Monitor real user satisfaction in production. ### 6. Re-benchmark After Changes When you update your document corpus, re-run benchmarks to verify quality. --- ## API Reference ### RAGASEvaluator (not compile-checked) ```scala class RAGASEvaluator(llmClient: LLMClient) { def evaluate(sample: EvalSample): Result[RAGASMetrics] def evaluateBatch(samples: Seq[EvalSample]): Result[Seq[RAGASMetrics]] } ``` ### EvalSample (not compile-checked) ```scala final case class EvalSample( question: String, answer: String, contexts: Seq[String], groundTruth: Option[String] = None ) ``` ### RAGASMetrics (not compile-checked) ```scala final case class RAGASMetrics( faithfulness: Option[Double], answerRelevancy: Option[Double], contextPrecision: Option[Double], contextRecall: Option[Double] ) { def ragasScore: Double // Harmonic mean of available metrics } ``` ### BenchmarkRunner (not compile-checked) ```scala class BenchmarkRunner(llmClient: LLMClient, embeddingClient: EmbeddingClient) { def runSuite(suite: BenchmarkSuite): BenchmarkResults def runExperiment(config: RAGExperimentConfig, dataset: Dataset): ExperimentResult } ``` --- ## Next Steps - **[Vector Store Guide](vector-store)** - Configure vector backends - **[Benchmark Results](../rag-benchmark-results.md)** - Detailed benchmark data - **[Examples Gallery](/examples/)** - RAG implementation examples --- Source: https://llm4s.org/guide/java # Using LLM4S from Java Call a model from Java with `llm4s-java-api`: a client, a prompt, a conversation, and failures you can read without Scala. --- ## What `llm4s-java-api` is LLM4S is written in Scala. Its core API returns Scala types (`Either`, `Option`, `Seq`) that are awkward to use from Java. `llm4s-java-api` is a small layer in front of it for Java callers: `Llm4s` creates a client, `JLlmClient` calls the model, `ConversationBuilder` builds a conversation, and every call returns an `LlmResult`, a result type modelled on `java.util.Optional` and `CompletableFuture`. A failed call is a value you check, not an exception you have to catch. It does not hide every Scala type. `Conversation` and `ProviderConfig` are Scala classes that still appear in its signatures, as does `LLMError` behind `LlmException.error()`, though [its kind](#handling-a-failure) reads as a Java enum, and so does core's `CompletionOptions`, in an overload that [`JCompletionOptions`](#completion-options) makes unnecessary. [What is not here yet](#what-is-not-here-yet) says which of them gets in your way. The values it hands you to read - `JCompletion`, `JAgentResult`, `JMessage`, `PendingInterrupt`, `JEmbeddings` and the rest - are Java classes whose constructors are package-private: you receive them, you do not build them, so a later release can add a field without breaking code compiled against this one. The classes that do the work come from a factory: `Llm4s` makes clients and agents, `JCompletionOptions.builder()` and `ConversationBuilder.create()` start a builder, `LlmResult.success` and `failure` make a result, and the `Answer` factories an answer. Their constructors are not for you either: each takes a first parameter of a type only the package can name, and throws `UnsupportedOperationException` when called from anywhere else. To call a Scala `LLMClient` you built yourself - a `ReliableClient` around a provider's client, your own implementation, a test double - wrap it with `Llm4s.wrapClient(client)`; a Scala `EmbeddingClient`, with `Llm4s.wrapEmbeddingClient(client, model)`. > `llm4s-java-api` is on Maven Central from `0.5.0` on; the current release is 0.5.1. > The [`gradle-java` sample](https://github.com/llm4s/llm4s/tree/main/modules/samples/gradle-java) is a complete Java > project you can copy. It needs **JDK 21**: it depends on `llm4s-agent`, which is compiled for JDK 21 and starts > virtual threads (see [Scala and JDK support](/reference/v1-scope#scala-and-jdk-support)). Whether some artifacts get a > lower floor before 1.0 is tracked in [#1493](https://github.com/llm4s/llm4s/issues/1493). ## Add the dependency The artifact is `org.llm4s:llm4s-java-api_3`. The `_3` is the Scala binary version, and LLM4S is Scala 3 only. sbt adds it for you (`%%`); Maven and Gradle do not, so write it yourself. **Maven** ```xml org.llm4s llm4s-java-api_3 0.5.1 ``` **Gradle (Kotlin DSL)** ```kotlin dependencies { implementation("org.llm4s:llm4s-java-api_3:0.5.1") } ``` **sbt** (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-java-api" % "0.5.1" ``` What arrives with it: - `llm4s-core`, `llm4s-agent` and the OpenAI, Anthropic, Ollama, Gemini and OpenAI-compatible provider modules, so you do not add a provider artifact. Making providers opt-in is tracked in [#1496](https://github.com/llm4s/llm4s/issues/1496). - Scala's standard library (`scala3-library_3` and `scala-library`). If your build pins or excludes it, see the [Gradle guide](../getting-started/gradle) for the recipes that keep the two Scala libraries aligned. - No logging backend. LLM4S logs through SLF4J; add one (for example Logback), or SLF4J prints a warning and drops the logs. ## Configure a provider Java code does not build a provider configuration. It comes from an `application.conf` on your classpath, the same file Scala users write: a named section per provider, and one section named as the default. ```hocon # src/main/resources/application.conf llm4s { providers { provider = "openai-main" # the default: the name of a section below openai-main { provider = "openai" model = "gpt-4o-mini" } } } ``` With `OPENAI_API_KEY` set in the environment, that is the whole configuration: the OpenAI module binds the variable to the shared credential for `openai`, so the section needs only `provider` and `model`. Other providers work the same way; `anthropic` reads `ANTHROPIC_API_KEY`, and `ollama` needs no key but does need a `baseUrl`: ```hocon llm4s { providers { provider = "claude" claude { # key from ANTHROPIC_API_KEY provider = "anthropic" model = "claude-sonnet-4-20250514" } ollama-local { provider = "ollama" model = "llama3.2" baseUrl = "http://localhost:11434" } } } ``` The [configuration guide](../getting-started/configuration) lists every key and every provider. Nothing in the library reads a `LLM_MODEL` variable; the model is the `model` key of the section. ### Configure in code To build a client in code instead of from a file, `Llm4s` has a builder for each provider this module brings: `openAi()`, `anthropic()`, `gemini()`, `ollama()`, `openAiCompatible()` (vLLM, LM Studio, LiteLLM, a gateway: any server that speaks OpenAI's chat-completions API) and `azureOpenAi()`. Each takes plain Java values and builds an `LlmResult`: ```java LlmResult openai = Llm4s.openAi() .apiKey(apiKey) .model("gpt-4o-mini") .build(); LlmResult local = Llm4s.ollama() .model("llama3.2") .build(); LlmResult gateway = Llm4s.openAiCompatible() .baseUrl("http://localhost:8000/v1") .model("qwen2.5-coder") .header("X-Team-Id", "platform") .build(); ``` What each provider needs is required - an API key and a model, for Ollama the model alone, for an OpenAI-compatible server its base URL and model, for Azure the endpoint, key and deployment - and everything else has the default `application.conf` would give it: a builder makes the same config as a section with the same values. That means the provider's own base URL (`http://localhost:11434` for Ollama), and the context window and reply reserve the model catalogue lists for the model; for an OpenAI-compatible server, the catalogue's window for the provider its base URL's host names (Groq, Together, Fireworks, xAI, Perplexity), else 8192 tokens. `baseUrl`, `contextWindow`, `reserveCompletion`, `requestTimeout` and `streamTimeout` (each a `java.time.Duration`, at most about 292 years) override them, as do `organization` for OpenAI, `apiVersion` for Azure and `header` for an OpenAI-compatible server. A context window set without a reserve keeps a quarter of itself for the reply, up to the reserve the provider would otherwise use, as a section's `contextWindow` does. `metrics(MetricsCollector)` makes the client report every call, as [`createClient(config, metrics)`](#recording-metrics) does. `build()` sends no request and does not throw. A required field left out - or set to `null` or a blank string, as `System.getenv` returns for a variable that is not set - is a failed result of kind `VALIDATION` whose message names it (`Field 'apiKey' is required`), as is a value out of range, such as a context window of zero. A builder is immutable, so one can be kept and forked: `base.model("gpt-4o")` returns a new builder and leaves `base` as it was. Its `toString` prints the API key as `***`, an OpenAI-compatible server's headers by name only, and a base URL or endpoint without its userinfo, query or fragment, so a builder can be logged. From Kotlin, build the client the same way and hand it to `llm4s-kotlin-api`'s `Llm4s.wrapClient`, which returns an `LLMClientKt` with suspend functions, `Flow` streaming and `LLMException` ([#1850](https://github.com/llm4s/llm4s/issues/1850)). `wrapClient` takes any `JLlmClient`, so a client from `createClient(ProviderConfig)` works the same way: ```kotlin import org.llm4s.kotlin.Llm4s import org.llm4s.javaapi.Llm4s as JLlm4s val client = Llm4s.wrapClient( JLlm4s.ollama() .model("llama3.2") .build() .get(), // throws the facade's LlmException on a VALIDATION failure; check isSuccess to handle it ) client.use { println(it.complete("What is 2 + 2?")) } // complete is a suspend function: call it in a coroutine ``` For any other provider - one from its own module, such as `llm4s-bedrock` - `Llm4s.createClient(ProviderConfig)` takes the provider's config object. OpenAI, Anthropic and Ollama have one with a Java entry point too, which takes the essentials and fills in the rest (the default base URL, and a context window and completion reserve guessed from the model name): ```java LlmResult openai = Llm4s.createClient(OpenAIConfig.apply(apiKey, "gpt-4o-mini")); LlmResult anthropic = Llm4s.createClient(AnthropicConfig.apply(apiKey, "claude-sonnet-4-20250514")); LlmResult local = Llm4s.createClient(OllamaConfig.apply("llama3.2", "http://localhost:11434")); ``` (The imports are listed under [The snippets' imports](#the-snippets-imports).) Reading the key is up to you; creating the client sends no request. The `with` methods of each config (`withContextWindow`, `withReserveCompletion`, and for OpenAI `withBaseUrl` and `withOrganization`) change one field at a time; some take a Scala `Option`, which the builders above avoid. ### Recording metrics `Llm4s.createClient(ProviderConfig, MetricsCollector)` builds a client that reports every call (its outcome, duration, tokens and cost) to a `MetricsCollector`: ```java LlmResult measured = Llm4s.createClient(OllamaConfig.apply("llama3.2", "http://localhost:11434"), metrics); ``` For `metrics`, use `new MicrometerMetrics(meterRegistry)` from `llm4s-observability-micrometer` to record into the application's Micrometer `MeterRegistry` (see [Micrometer metrics](observability/micrometer)), or `PrometheusMetrics` from `llm4s-observability-prometheus`. A null collector records nothing. The Spring Boot starter does this for you when the application has a `MeterRegistry`. A [client builder](#configure-in-code) takes the collector too: `Llm4s.ollama().model("llama3.2").metrics(metrics).build()`. ### Behind a proxy or a corporate CA Every client builder takes the proxy and TLS settings that `llm4s.http` gives a configured provider ([Corporate networks](../getting-started/configuration#corporate-networks-a-proxy-and-a-custom-ca)): an HTTP proxy, its Basic credentials, the hosts reached directly, a trust store whose CAs are trusted in addition to the JDK's, and, for a gateway that requires mutual TLS, a key store with the client certificate (`keyStore(path, password)`): ```java LlmResult corporate = Llm4s.openAi() .apiKey(apiKey) .model("gpt-4o") .proxy("http://proxy.corp:3128") .proxyCredentials(proxyUser, proxyPassword) .nonProxyHosts("localhost", "*.internal") .trustStore(java.nio.file.Path.of("/etc/pki/corp-ca.p12"), trustStorePassword) .build(); ``` A proxy URL the builder cannot use, or a store it cannot open, makes `build()` return a failed result of kind `VALIDATION` naming the field; `toString` prints every password as `***`. ## Your first call ```java import java.io.PrintStream; import org.llm4s.javaapi.JLlmClient; import org.llm4s.javaapi.Llm4s; import org.llm4s.javaapi.LlmResult; public final class HelloLlm4s { private HelloLlm4s() {} public static void main(String[] args) { System.exit(run(System.out, System.err)); } static int run(PrintStream out, PrintStream err) { LlmResult created = Llm4s.createDefaultClient(); if (created.isFailure()) { err.println("Could not create a client: " + created.getError().getMessage()); return 1; } // JLlmClient is AutoCloseable: it holds an HTTP client, so close it. try (JLlmClient client = created.get()) { LlmResult answer = client.complete("What is a monad? Answer in one sentence."); answer .ifSuccess(text -> out.println(text)) .ifFailure(error -> err.println("The call failed: " + error.getMessage())); return answer.isSuccess() ? 0 : 1; } } } ``` `Llm4s.createDefaultClient()` loads the default provider section and builds a client from it. It does not throw: a missing section, a missing API key or an unknown provider is a failed `LlmResult`, and its message says what to fix (for a missing key it names the environment variable and the section). `JLlmClient` is `AutoCloseable` and closing it closes the underlying client, so use try-with-resources. `ifSuccess` and `ifFailure` each take a lambda and return the result, so they chain. The `run` method is separate from `main` so that a test can pass its own streams. The [`gradle-java` sample](https://github.com/llm4s/llm4s/tree/main/modules/samples/gradle-java) is this program in a complete Gradle project. ### The snippets' imports The snippets below leave out their imports. Together they use these: ```java import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.Flow; import org.llm4s.javaapi.CompletionStream; import org.llm4s.javaapi.ConversationBuilder; import org.llm4s.javaapi.FakeLlmClient; import org.llm4s.javaapi.JCompletion; import org.llm4s.javaapi.JCompletionOptions; import org.llm4s.javaapi.JEmbeddingClient; import org.llm4s.javaapi.JEmbeddingPurpose; import org.llm4s.javaapi.JEmbeddings; import org.llm4s.javaapi.JLlmClient; import org.llm4s.javaapi.JReasoningEffort; import org.llm4s.javaapi.JTool; import org.llm4s.javaapi.JToolArgs; import org.llm4s.javaapi.JToolCall; import org.llm4s.javaapi.Llm4s; import org.llm4s.javaapi.LlmErrorKind; import org.llm4s.javaapi.LlmException; import org.llm4s.javaapi.LlmResult; import org.llm4s.javaapi.Param; import org.llm4s.javaapi.Tool; import org.llm4s.llmconnect.config.AnthropicConfig; import org.llm4s.llmconnect.config.OllamaConfig; import org.llm4s.llmconnect.config.OpenAIConfig; import org.llm4s.llmconnect.model.Conversation; import org.llm4s.metrics.MetricsCollector; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; ``` ## A conversation A conversation is a list of messages. `ConversationBuilder` builds one without Scala syntax: ```java Conversation conversation = ConversationBuilder.create() .system("You answer in one short sentence.") .user("What is a monad?") .build(); LlmResult answer = client.complete(conversation); System.out.println(answer.get()); ``` The builder has `system`, `user` and `assistant`, and messages are sent in the order you add them. It is immutable: each method returns a new builder, so one can be shared and extended. A `null` message throws `NullPointerException` at once. ## Completion options `JCompletionOptions` sets how the model answers one request, built with a builder and passed with the conversation: ```java JCompletionOptions options = JCompletionOptions.builder() .temperature(0.2) .maxTokens(512) .reasoning(JReasoningEffort.MEDIUM) .build(); LlmResult answer = client.complete(conversation, options); System.out.println(answer.get()); ``` The builder has `temperature`, `topP`, `maxTokens`, `presencePenalty`, `frequencyPenalty`, `reasoning` (the Java enum `JReasoningEffort`: `NONE`, `LOW`, `MEDIUM` or `HIGH`) and `budgetTokens`, an explicit thinking budget that overrides the one the reasoning level implies. The providers that take a budget are Anthropic, and OpenRouter for Claude models: both raise a budget below `1024` to `1024` and keep it below the token limit, and a budget set with no reasoning level still turns thinking on. A setting you leave out keeps the library's default: temperature `0.7`, top-p `1.0`, no penalties, and no token limit, reasoning level or thinking budget. The built options have an accessor of the same name for each setting. `maxTokens()` and `budgetTokens()` return an `OptionalInt` and `reasoning()` an `Optional`, empty when unset. Their setters also take an `OptionalInt` or `Optional`, so an empty one clears the value. The builder is immutable like `ConversationBuilder`: each setter returns a new builder, and `options.toBuilder()` starts one from existing options. A value no provider accepts throws `IllegalArgumentException` at once: a negative or non-finite temperature, a top-p outside `0` to `1`, a non-finite penalty, or a token count below `1`. A `null` throws `NullPointerException`. A value only some models reject, such as a temperature above `1` for some models, is left to the provider. A model that does not reason ignores `reasoning`. Tools and response formats are not options here yet. ## The whole reply `complete` returns the reply's text. `completion` takes the same arguments - a query, a conversation, or a conversation and `JCompletionOptions` - and returns the whole reply as a `JCompletion`: the model that answered, the tokens it used, its estimated cost and the tool calls it asked for, alongside the text: ```java JCompletion reply = client.completion(conversation).get(); System.out.println(reply.model() + ": " + reply.content()); reply.usage().ifPresent(usage -> System.out.println(usage.promptTokens() + " tokens in, " + usage.completionTokens() + " out")); reply.estimatedCost().ifPresent(cost -> System.out.println("about $" + cost.toPlainString())); for (JToolCall call : reply.toolCalls()) { System.out.println("wants " + call.name() + " " + call.argumentsJson()); } ``` `model()` is the model as the provider names it, which can be more specific than the one you configured. `usage()` is a `JTokenUsage` with `promptTokens()`, `completionTokens()`, `totalTokens()` and `thinkingTokens()`, and the prompt-cache counts `cachedTokens()` (input read from the provider's prompt cache, billed at the cheaper cache-read rate) and `cacheCreationTokens()` (input written into it, typically billed above the normal input rate) - each an `int`, zero when the model reported none - or empty when the provider reported no usage. `estimatedCost()` is a `java.math.BigDecimal` in USD, empty when the cost is not known; it is the figure an agent adds to its `usage().totalCost()`. `toolCalls()` lists `JToolCall`s, the same type an agent's messages use, with the arguments as JSON text; `thinking()` is the model's reasoning text when it reported one. A `JCompletion` is a value with `equals`, `hashCode` and a `toString` that prints the full text. The method is `completion`, a noun, because it returns the reply rather than performs a request for its text; `complete` keeps returning a `String`. There is no `completion` overload for core's Scala `CompletionOptions`, so `client.completion(conversation, null)` compiles, and returns a failed result like any other `null` argument. ## Streaming the reply `stream` sends the same arguments as `complete` - a query, a conversation, or a conversation and `JCompletionOptions` - with a `CompletionStreamListener`, and returns at once. The listener receives each piece of the reply's text as it arrives (`onPartial`), then the whole reply (`onComplete`, a `JCompletion`) or the error (`onError`); only `onPartial` is abstract, so a lambda or a method reference is a listener: ```java JCompletion reply; try (CompletionStream stream = client.stream(conversation, System.out::print).get()) { reply = stream.await().get(); } System.out.println(); Flow.Publisher pieces = client.streamAsPublisher(conversation); ``` The returned `CompletionStream` awaits the reply (`await()`, the same outcome the listener heard) and cancels the call (`cancel()`, or `close()`, so try-with-resources stops a call its block leaves). Cancelling interrupts the provider's thread, which is how llm4s providers stop, and the listener then hears `onError` with a `CANCELLED` error. Listener calls are made one at a time, never on your thread; one that throws stops the call, and `onError` receives what it threw. `streamAsPublisher` returns the text as a `java.util.concurrent.Flow.Publisher` instead: it is in the JDK, so Reactor (`JdkFlowAdapter`), RxJava and Mutiny adapt it without another llm4s dependency, and `llm4s-reactor` returns it as a `Flux` ([Java Reactor](java-reactor)). Each subscription makes its own call. A subscriber slower than the model holds the provider's thread rather than losing text, and cancelling the subscription cancels the call. A failed call ends with `onError` (an `LlmException`) after every piece that streamed before the failure. Streaming yields text only; tool-call deltas are not streamed. ## Structured output Pass a record class after the query, or after the conversation (and options), and `complete` returns an instance of it instead of text. The record's components become a JSON Schema that is sent as the structured-output format, and the answer is read back into the record: ```java record City(String name, String country, int population) {} City city = client.complete("The largest city in Japan, with its population", City.class).get(); System.out.println(city.name() + ", " + city.country() + ": " + city.population()); ``` A component can be a `String`, an `int`, `long`, `short` or `byte` (boxed or not), a `double` or `float`, a `boolean`, an enum (the model answers with a constant's name), another record, a `java.util.List` of any of these, or an `Optional` of any of these. `Optional` is the only way to let the model answer `null` or leave a field out; every other field is required. A `Map`, an array, a raw `List`, an interface or a record that contains itself is refused before any call, and an answer that does not fit - a missing field, the wrong JSON type, a fraction for an `int`, an unknown enum name - is a failed result whose message names the field, such as `field 'previous[0].city': expected a string, got a number`. Numbers pass through a `double`, so ask for an identifier above 2^53 as a `String`. How firmly the schema binds the model is the provider's: OpenAI-style providers send it as a native JSON Schema, Anthropic adds it to the system prompt, and watsonx and Bedrock ignore it, so the answer is still checked when it comes back. See [Structured output](structured-output) for each provider. ## Asynchronous calls Every blocking call has an `*Async` variant that returns a `CompletableFuture` at once and makes the call on another thread: `completeAsync` and `completionAsync` on `JLlmClient` (with the same arguments as `complete` and `completion`, a record class for structured output included), `runAsync`, `continueConversationAsync`, `resumeAsync` and `recoverAsync` on `JAgent`, and `embedAsync` on `JEmbeddingClient`. Fan several calls out and wait for all of them with `allOf`, or bound one with `orTimeout`: ```java CompletableFuture first = client.completeAsync("Summarise the first report"); CompletableFuture second = client.completeAsync("Summarise the second report"); CompletableFuture turn = agent.runAsync("Plan the release"); CompletableFuture.allOf(first, second, turn).join(); System.out.println(first.join() + " / " + second.join() + " / " + turn.join().answer().orElse("")); CompletableFuture answer = client.completeAsync("A long question").orTimeout(30, TimeUnit.SECONDS); ``` A failed call completes its future exceptionally with the `LlmException` that the blocking call's `LlmResult` would hold, of the same `LlmErrorKind`, so `join()` throws a `CompletionException` and `get()` an `ExecutionException` whose cause is that `LlmException`. A future you cancel throws `CancellationException`, as any `CompletableFuture` does. `cancel(true)` interrupts the call in flight, which is how llm4s providers stop; an agent turn cancelled this way ends as the blocking call interrupted does: cancelled, the conversation of a continued, resumed or recovered turn left for `recover`. Completing the future yourself - `orTimeout`, `completeOnTimeout`, `complete` - interrupts the call too, since its answer can no longer be delivered. `cancel(false)` lets the call finish and discards its answer. Cancel the future the method returned: a future derived from it with `thenApply` does not pass its cancellation back. By default each call runs on a new virtual thread. A `JLlmClient` has an executor of its own for that, which `close()` shuts down: a call still running is then interrupted, and one made after it fails with kind `OTHER`. `withAsyncExecutor` runs the calls on an executor of yours instead - a bounded pool, a container's managed executor: ```java ExecutorService pool = Executors.newFixedThreadPool(8); JLlmClient pooled = client.withAsyncExecutor(pool); // shares client's connection; never shuts pool down ``` The pooled client shares the connection of the client it came from, so closing either closes it, and it never shuts the pool down. `JAgent` and `JEmbeddingClient` have `withAsyncExecutor` too. Any executor works, a `ForkJoinPool` included: cancelling a future interrupts the thread running its call whatever the executor. An executor that refuses a call - shut down, or its queue full - fails that call's future at once with kind `OTHER`, the executor's `RejectedExecutionException` its cause. The [threading guide](java-threading-and-cancellation) says which thread runs what. ## Reading a result Every `complete` call returns an `LlmResult`, and every `completion` call an `LlmResult`. There are several ways to take the value out, depending on how you want to treat a failure: ```java LlmResult result = client.complete("What is 2+2?"); String text = result.get(); // the value, or throws LlmException String orNull = result.getOrNull(); // the value, or null when the call failed Optional optional = result.toOptional(); // Optional.empty() when the call failed LlmResult length = result.map(String::length); CompletableFuture future = result.toCompletableFuture(); ``` `isSuccess()` and `isFailure()` test it, `getError()` returns the `LlmException` of a failure (or `null`), and `map` transforms a success and passes a failure through. `toCompletableFuture()` returns a future that is already complete: it makes a result fit an API that expects a future, and does not make the call asynchronous (see [Asynchronous calls](#asynchronous-calls)). ## Handling a failure A call that fails does not throw. `JLlmClient` turns a failure of the provider, the network or the library into a failed `LlmResult`, and a `null` argument into a failed result too. Only `get()` throws, and it throws `LlmException`, an unchecked exception that says what went wrong in Java types: ```java try { String text = client.complete("What is 2+2?").get(); System.out.println(text); } catch (LlmException e) { System.err.println(e.getMessage()); switch (e.getKind()) { case AUTHENTICATION, CONFIGURATION -> System.err.println("check the API key and the provider section"); case RATE_LIMIT -> System.err.println("rate limited; wait " + e.getRetryAfter().map(d -> d.toSeconds() + " s").orElse("a while")); case SERVICE -> System.err.println("the provider answered HTTP " + (e.getStatusCode().isPresent() ? e.getStatusCode().getAsInt() : "?")); default -> { } } if (e.isRecoverable()) { System.err.println("a retry may succeed"); } } ``` `getKind()` is the Java enum `LlmErrorKind`: | Kind | The failure | Recoverable | |---|---|---| | `AUTHENTICATION` | the provider rejected the API key | no, unless it is a provider's `401` or `403` reported as a service error | | `RATE_LIMIT` | the provider, or a local limiter, refused the call for now | yes, unless it is an embedding provider's error | | `TIMEOUT` | the call did not finish in time | yes | | `NETWORK` | the provider could not be reached | yes | | `SERVICE` | the provider answered with an error | yes, unless it is an embedding provider's error | | `VALIDATION` | the request is wrong: a missing or invalid argument | no, unless it is a provider's `400` reported as a service error | | `CONFIGURATION` | a setting is missing or invalid | no | | `CANCELLED` | the call was cancelled, for example by an interrupt | no | | `OTHER` | anything else, including an error from another llm4s module or your own code | depends on the error | A provider's error response with status `400`, `401`/`403` or `429` has the kind that status means (`VALIDATION`, `AUTHENTICATION`, `RATE_LIMIT`) even when a provider client reported it as a generic service error. Such an error keeps the recoverability of its class: a `ServiceError` or an `APIError` is recoverable whatever its status, so a `403` reported as one reads as `AUTHENTICATION` and `isRecoverable()` is `true`. The table's column is what the kind's own error classes say; to decide whether to try again, always call `isRecoverable()` rather than infer it from the kind. Every error class in `org.llm4s.error` has a kind, and a test fails when a new one is added without one. An [embedding](#embeddings) provider's error response has the kind of its HTTP status in the same way, but llm4s does not call any embedding error recoverable, so `isRecoverable()` is `false` for it whatever its kind. `isRecoverable()` says whether the call may succeed if tried again, perhaps after you do something first: wait out a rate limit, or correct a request the provider rejected. `getRetryAfter()` is the `Optional` the provider asked you to wait (its `Retry-After` header), empty when it did not say, and `getStatusCode()` the `OptionalInt` HTTP status of a provider's error response. If the error carries a `Throwable`, it is the exception's `getCause()`. `e.error()` is still the llm4s error itself, a Scala `LLMError` whose `message()` and `formatted()` (the message plus its code and context) are plain methods, and whose classes - `RateLimitError`, `ServiceError` and the rest - you can test with `instanceof` for a detail the kind does not carry. The [error handling guide](error-handling) has the full list and the recovery tools, written for Scala. If the thread blocked in `complete` is interrupted, the call returns a failed result whose error is a `CancelledError`, with the thread's interrupt flag still set. `InterruptedException` is never thrown, so the method does not declare it and Java will not let you write `catch (InterruptedException e)` around the call; test the result for a `CancelledError` instead. The [threading and cancellation guide](java-threading-and-cancellation) has the details. ## An agent turn `Llm4s.createAgent(client)` wraps a client in a `JAgent`. `run` and `continueConversation` block, like `complete`, and return an `LlmResult`. A `JAgentResult` is read with JDK types and this module's own: `answer()` is an `Optional`, `messages()` a `java.util.List`, and `status().kind()` the Java enum `AgentStatusKind`, so a `switch` covers every way a turn ends: ```java JAgent agent = Llm4s.createAgent(client); JAgentResult result = agent.run("What is 2+2?").get(); switch (result.status().kind()) { case COMPLETED -> System.out.println(result.answer().orElseThrow()); case BLOCKED -> System.out.println("Blocked by " + result.status().guardrail().orElseThrow()); case STEP_LIMIT_REACHED -> System.out.println("Hit the step limit"); case TOKEN_LIMIT_REACHED -> System.out.println("Hit the token budget"); case SUSPENDED -> System.out.println("Waiting for " + result.status().pending().size() + " answers"); } for (JMessage message : result.messages()) { System.out.println(message.role() + ": " + message.content()); } JUsageSummary usage = result.usage(); System.out.println(usage.inputTokens() + " tokens in, " + usage.outputTokens() + " out"); JAgentResult next = agent.continueConversation(result, "And 3+3?").get(); ``` Each status's data has its own accessor, empty for the other kinds: `answer()` for `COMPLETED`, `guardrail()` and `reason()` for `BLOCKED`, and `pending()` for `SUSPENDED` - the approvals and questions the turn waits for, which the agent guide's [suspended turns](agents/#suspended-turns-from-java-and-kotlin) section answers. A `JMessage` has a `role()` (the Java enum `JMessageRole`), its `content()`, the `toolCalls()` an assistant message asked for, with their arguments as JSON text (an object, as a model sends them; a call built with a `ujson.Str` renders as a JSON string literal), and the `toolCallId()` a tool message answers. `usage()` counts tokens as `long`s, the cost as a `java.math.BigDecimal` (two usages are equal when their costs are numerically equal, whatever the scale), and `byModel()` breaks both down per model. No accessor returns a Scala or `ujson` type. These types are values, but not `Serializable`. Their `toString` prints the full text - a message's content, a tool call's arguments, an answer or a guardrail's reason - as the Scala types do, so mind what you log. ## Tools An agent can call tools you write in Java, in either of two styles. Annotate the public methods of a class with `@Tool`, and each parameter with `@Param` (its `name` is what the model passes; without one, the compiled name is used, which needs `javac -parameters`): ```java class WeatherTools { @Tool(description = "The current weather in a city") public String weather(@Param(name = "city", description = "City name") String city) { return "Sunny in " + city; } } ``` or build one from a lambda with `JTool.builder`, then give the tools to `Llm4s.createAgentWithTools`: ```java List weather = JTool.fromAnnotated(new WeatherTools()).get(); LlmResult add = JTool.builder("add", "Adds two numbers") .param("a", long.class, "First") .param("b", long.class, "Second") .handler(args -> args.getLong("a") + args.getLong("b")) .build(); JAgent agent = Llm4s.createAgentWithTools(client, List.of(weather.get(0), add.get())).get(); JAgentResult result = agent.run("What is the weather in Paris?").get(); ``` A parameter is a `String`, an `int`, `long`, `short` or `byte` (boxed or not), a `double` or `float`, a `boolean`, or an enum (the model passes a constant's name); its JSON Schema is derived from the type, and `optionalParam` (or `@Param(required = false)`, with a boxed type) lets the model leave it out. The model's arguments are read into those Java values before your code runs, with no JSON library: a missing or mistyped argument is a tool error the model sees, and so is an exception your method or lambda throws - the agent keeps running and the model can try again. A result is passed to the model as text if it is a `String`; a number, boolean, enum, `Map` or `Collection` becomes JSON. A tool that cannot be built (no handler, an unsupported parameter type, a parameter declared twice) and an agent given two tools with one name are failed results, not exceptions. An agent runs a tool at least once, not exactly once: a retry, an approval, an answered question or a `recover` can run the same call again (see [Tool side effects](agents/#tool-side-effects)). A tool with an external side effect therefore passes the call's idempotency key to the system it reaches, which then does the work once. A lambda reads it from its arguments, as `args.idempotencyKey()`; an annotated method declares a `JToolArgs` parameter, which the model does not see, and reads it there: ```java @Tool(description = "Charges a card once") public String charge(@Param(name = "cents", description = "Amount in cents") long cents, JToolArgs call) { return billing.charge(cents, call.idempotencyKey().orElse(null)); // empty outside an agent run } ``` ```java LlmResult refund = JTool.builder("refund", "Refunds a charge once") .param("charge", String.class, "The charge to refund") .handler(args -> billing.refund(args.getString("charge"), args.idempotencyKey().orElse(null))) .build(); ``` `idempotencyKey()` and `toolCallId()` (the id the model gave the call) are `Optional`s: present for every call an agent runs, the key the same in every run of one call, and empty when the tool runs outside an agent. ## The Kotlin DSL `llm4s-kotlin-api` declares the same tools, options and clients with a Kotlin DSL ([#1495](https://github.com/llm4s/llm4s/issues/1495)). Each block builds the Java facade's object - a `JTool`, a `JCompletionOptions`, a provider's client builder - so it behaves exactly as the Java code above, and a failure throws `LLMException` (an option out of range, `IllegalArgumentException`) instead of returning a failed `LlmResult`: ```kotlin import org.llm4s.javaapi.JReasoningEffort import org.llm4s.kotlin.Llm4s import org.llm4s.kotlin.completionOptions import org.llm4s.kotlin.tools import kotlin.time.Duration.Companion.seconds val client = Llm4s.openAi { // also anthropic, gemini, ollama, openAiCompatible, azureOpenAi apiKey = System.getenv("OPENAI_API_KEY") model = "gpt-4o-mini" requestTimeout = 30.seconds } val reply = client.complete("Name a colour", completionOptions { temperature = 0.2 maxTokens = 200 reasoning = JReasoningEffort.LOW }) val agent = Llm4s.createAgent(client, tools { tool("add", "Adds two numbers") { param("a", "First") param("b", "Second") handler { args -> args.get("a") + args.get("b") } } tool("refund", "Refunds a charge once") { param("charge", "The charge to refund") handler { args -> billing.refund(args.get("charge"), args.idempotencyKey) } } }) val result = agent.run("What is 2 + 3?") // a suspend function: call it in a coroutine ``` A client block takes the fields of its Java builder (see [Configure in code](#configure-in-code)), with a `kotlin.time.Duration` for each timeout; `completionOptions(base) { ... }` starts from existing options. `tool` declares one tool and `tools` a list of them, to which `add` appends one built with `JTool.builder` or `JTool.fromAnnotated`. `param` and `optionalParam` take the types `JTool.builder` does - `String`, `Int`, `Long`, `Short`, `Byte`, `Double`, `Float`, `Boolean` or an enum - and the handler reads them with `args.get(name)`, or `args.getOrNull(name)` for an optional one the model may leave out. `args.idempotencyKey` and `args.toolCallId` describe the call ([#1740](https://github.com/llm4s/llm4s/issues/1740)), `null` outside an agent run; `args.java` is the `JToolArgs` underneath. The handler is a `suspend` function. The agent runs a tool on a thread of its own, which waits for the handler in a coroutine of its own (`runBlocking`), not in the caller's. Cancelling the coroutine that runs the turn - the caller of `run`, a collector of `stream`, a `withTimeout` - cancels the turn, which interrupts that thread and so cancels the handler: a suspended `delay`, `withContext` or other cancellable call throws `CancellationException`. ## Embeddings An embedding model turns a text into a vector, a `float[]`, and texts that mean similar things get vectors that point in similar directions: the basis of semantic search. `Llm4s.createDefaultEmbeddingClient()` creates a `JEmbeddingClient` for the model `application.conf` names, as `createDefaultClient()` does for chat. The model is `provider/model` under `llm4s.embeddings`, or the `EMBEDDING_MODEL` environment variable: ```hocon llm4s { embeddings { model = "openai/text-embedding-3-small" # or set EMBEDDING_MODEL } } ``` The key comes from the vendor's variable, `OPENAI_API_KEY` here, as a chat section's does. The embedding providers `llm4s-java-api` brings are `openai` and `ollama`; Voyage, Jina and Cohere come with their own modules (`llm4s-voyage`, `llm4s-jina`, `llm4s-cohere`). Creating the client sends no request, and does not throw: ```java LlmResult created = Llm4s.createDefaultEmbeddingClient(); created.ifFailure(error -> System.err.println("Could not create an embedding client: " + error.getMessage())); JEmbeddingClient embedder = created.getOrNull(); // null when it failed ``` No model configured, a provider that is not on the classpath or a missing key is a failed result of kind `CONFIGURATION`, whose message says what to set. `embedder.model()` is the model as configured, and `embedder.dimensions()` an `OptionalInt`: its vector length as the provider's module declares it, or empty for a model the module does not list, such as an Ollama model you pulled yourself - that model still works, and its vectors are as long as the provider makes them. To embed with a model other than the one `application.conf` names - two models side by side, say - `Llm4s.createEmbeddingClient` takes it as `provider/model`. The provider's key, base URL and timeouts still come from configuration, as for the default client, so `llm4s.embeddings.model` need not be set at all: ```java JEmbeddingClient large = Llm4s.createEmbeddingClient("openai/text-embedding-3-large").get(); JEmbeddingClient local = Llm4s.createEmbeddingClient("ollama/bge-m3").get(); // dimensions() is empty ``` A `null` model is a failed result of kind `VALIDATION`; one not in the form `provider/model`, a provider that is not on the classpath or a missing key, one of kind `CONFIGURATION`. `embed` takes a `java.util.List` and returns a `JEmbeddings`: one vector per text, in the order of the texts, from a single request. `JEmbeddings.cosineSimilarity` compares two of them: ```java JEmbeddings embeddings = embedder.embed(List.of( "The cat sat on the mat.", "A kitten was sitting on the rug.")).get(); List vectors = embeddings.vectors(); double similarity = JEmbeddings.cosineSimilarity(vectors.get(0), vectors.get(1)); System.out.println(embeddings.model() + ", " + embeddings.dimensions() + " dimensions: similarity " + similarity); ``` `model()` is the model as the provider named it in its reply, and `dimensions()` the length of every vector. `vectors()` returns a new unmodifiable list of new arrays on each call, so changing one changes nothing; call it once and keep the list. A `JEmbeddings` is a value, with `equals` and `hashCode` over its vectors' components. Core's vectors are `double`s; these are their nearest `float`s, the precision vector stores keep. `cosineSimilarity` goes from `-1` (opposite) through `0` (unrelated) to `1` (the same direction). A zero vector has no direction, so its similarity to anything is `0`. Two vectors of different lengths, which come from different models, throw `IllegalArgumentException`, and a `null` throws `NullPointerException`. Several embedding models embed a search query differently from the documents it searches, and comparing a query embedded as a document quietly finds worse matches. Say which side a text is on with `JEmbeddingPurpose`: ```java List documents = embedder.embed(texts, JEmbeddingPurpose.DOCUMENT).get().vectors(); float[] query = embedder.embed(List.of("Where did the cat sit?"), JEmbeddingPurpose.QUERY).get().vectors().get(0); int best = 0; for (int i = 1; i < documents.size(); i++) { if (JEmbeddings.cosineSimilarity(query, documents.get(i)) > JEmbeddings.cosineSimilarity(query, documents.get(best))) { best = i; } } System.out.println("closest: " + texts.get(best)); ``` `embed(texts)` without a purpose embeds documents. Voyage and Cohere send the purpose as `input_type` and Jina as `task`; OpenAI and Ollama embed both alike and ignore it, so code that says which side it is on works with any of them. Like `complete`, `embed` blocks and never throws: an empty list returns no vectors without a request, a `null` list, text or purpose is a failed result of kind `VALIDATION`, and an interrupt one of kind `CANCELLED`. A provider's error response has the kind of its HTTP status - `401` reads as `AUTHENTICATION`, `429` as `RATE_LIMIT`, `400` as `VALIDATION`, any other as `SERVICE`, with `getStatusCode()`. A provider that cannot be reached is `NETWORK`, and one that does not answer in time `TIMEOUT`; any other failure with no status, such as a reply that cannot be read, is `OTHER`. To keep the vectors in a store, search them and answer questions from what they find, see [Java RAG](java-rag) (`llm4s-java-rag`). ## Testing your code For a complete runnable example, see the [Java offline ticket classifier](../examples/#java-offline-example). It injects a `JLlmClient`, handles unknown replies and provider failures, and checks recorded prompts in its test. `FakeLlmClient` is a fake model for your own tests: it answers from a script, records every prompt your code sent, and needs no network or API key. Its `client()` is a real `JLlmClient`, so the code under test needs no interface or mock - give it the fake's client wherever it takes one: ```java class CalculatorTest { @Test void answersFromTheModel() { FakeLlmClient fake = FakeLlmClient.replying("4"); String answer = new Calculator(fake.client()).ask("What is 2+2?"); assertEquals("4", answer); assertEquals(List.of("What is 2+2?"), fake.prompts()); } @Test void reportsAProviderFailure() { FakeLlmClient fake = FakeLlmClient.builder() .failWith(LlmErrorKind.RATE_LIMIT, "slow down") .build(); LlmResult result = fake.client().complete("What is 2+2?"); assertTrue(result.isFailure()); assertEquals(LlmErrorKind.RATE_LIMIT, result.getError().getKind()); } } ``` `Calculator` stands for your own class: here, one whose `ask` returns `client.complete(question).getOrNull()`. `FakeLlmClient.replying("a", "b")` answers the first call with `a` and the second with `b`; `builder()` mixes replies (`reply`) and failures (`failWith`, with the `LlmErrorKind` that `getKind()` then reports). A call beyond the script is a failed result whose message names the call and its prompt, so a test that makes more calls than it scripted says why. `callCount()`, `prompts()` (the last user message of each call, in order) and `lastPrompt()` show what your code sent. The fake is in `llm4s-java-api` itself, so a test needs no other dependency. It answers `complete`, `completion` and structured output; for an agent with tools from Scala, `llm4s-testkit`'s `ScriptedLLMClient` scripts tool calls too ([Testing with the Testkit](testing-with-the-testkit)), and `Llm4s.wrapClient` turns it into a `JLlmClient`. ## What is not here yet `llm4s-java-api` covers a client, a conversation, the whole reply, the kind of a failure and embeddings. These are not available from Java yet, each with the issue that tracks it: | You may expect | State today | |---|---| | Agents beyond a turn | [An agent turn](#an-agent-turn) reads a result; the agent guide covers [streaming a turn](agents/streaming#java-and-kotlin) and [suspended turns](agents/#suspended-turns-from-java-and-kotlin) from Java. Tools are defined from Java in [Tools](#tools) | ## Where next - [Java RAG](java-rag): ingest documents and ask questions about them, with `llm4s-java-rag`. - [Java Reactor](java-reactor): a streamed reply as a Reactor `Flux`, with `llm4s-reactor`. - [Spring Boot](spring-boot): auto-configuration, a template bean and a health indicator on top of this module. - [Quarkus](quarkus): the client as a CDI bean, configured from `application.properties`, behind a REST endpoint. - [Basic usage](basic-usage) and [providers](providers): the same concepts in Scala, and every provider's configuration. - [Gradle integration](../getting-started/gradle): dependency recipes for Gradle builds. - The [`gradle-java` sample](https://github.com/llm4s/llm4s/tree/main/modules/samples/gradle-java): a Java project you can run. --- Source: https://llm4s.org/guide/java-threading-and-cancellation # Java Threading and Cancellation Which thread a call blocks, whether one client can be shared, how to run calls on virtual threads and what an interrupt does, for code that uses `llm4s-java-api`. --- Every statement on this page is checked by a test in [`ThreadingModelSpec`](https://github.com/llm4s/llm4s/blob/main/modules/java-api/src/test/scala/org/llm4s/javaapi/ThreadingModelSpec.scala) unless it says otherwise, and was observed on JDK 21. Virtual threads need JDK 21. The Java snippets below are compiled by [`ThreadingGuideSnippets.java`](https://github.com/llm4s/llm4s/blob/main/modules/java-api/src/test/java/org/llm4s/javaapi/ThreadingGuideSnippets.java). ## Which calls block `JLlmClient.complete(...)`, `JAgent.run(...)`, `JAgent.continueConversation(...)`, `JAgent.resume(...)`, `JAgent.recover(...)` and `JEmbeddingClient.embed(...)` block the calling thread until the answer is back. Each has an `*Async` variant - `completeAsync`, `completionAsync`, `runAsync`, `continueConversationAsync`, `resumeAsync`, `recoverAsync`, `embedAsync` - that returns a `CompletableFuture` at once and makes the same call on another thread (see [Asynchronous calls](java#asynchronous-calls)). `JAgent.stream`, `streamResume` and `streamRecover` return an `AgentStream` at once too. `LlmResult.toCompletableFuture()` does not make a call asynchronous: it returns a future that is already complete, so `cancel(true)` on it returns `false` and changes nothing. To run a call off your own thread, use its `*Async` variant: ```java CompletableFuture answer = client.completeAsync("Summarise this."); String text = answer.join(); // waits on your thread; answer.cancel(true) interrupts the call ``` The call runs on a new virtual thread, named `llm4s-async-N`, of an executor the client owns and `close()` shuts down, or on the executor that `withAsyncExecutor` names. `JAgent` and `JEmbeddingClient`, which have no `close()`, start a virtual thread of their own for each call unless `withAsyncExecutor` names an executor. ## Which thread runs the call | Call | The provider call runs on | |------|---------------------------| | `JLlmClient.complete` | your thread, whether it is a platform thread or a virtual thread | | `JLlmClient.completeAsync` | a virtual thread of the client's own executor, or a thread of the executor `withAsyncExecutor` names | | `JAgent.run` | a virtual thread of the library's, never yours: your thread waits in `run`, and the model call runs on a virtual thread of its own | The library drives an agent run from a virtual thread it starts with `Thread.ofVirtual()` ([`RunHandle.scala`](https://github.com/llm4s/llm4s/blob/main/modules/agent/src/main/scala/org/llm4s/agent/graph/RunHandle.scala)), and runs each task of the run, the model call included, on a virtual thread of its own, forked with the Ox structured-concurrency library: the `TaskExecutor` in [`GraphBuilder.scala`](https://github.com/llm4s/llm4s/blob/main/modules/agent/src/main/scala/org/llm4s/agent/graph/GraphBuilder.scala) documents that "the default runs them concurrently on virtual threads in an Ox scope". Virtual threads are always daemon threads. The test checks the thread the model call runs on: not the caller, virtual and a daemon. On JDK 21 that thread had an empty name (observed, not pinned by a test), so look for the run thread, named `llm4s-run-`, in a thread dump instead. ## Sharing one client or agent One `JLlmClient` and one `JAgent` can be used by many threads at once. The tests start 50 platform threads on one client and 32 virtual threads on one agent, hold every call inside the client until all of them are in flight together, and check that each thread gets the answer to its own query. ## Virtual threads Calls from virtual threads work, and a call does not hold on to the virtual thread's carrier thread while it waits for the provider. The test starts more virtual threads than the machine has cores (at least 64), has a local server that answers only once every request has arrived, and checks that all of them were served at once through `JLlmClient` and the real Ollama client. A call that pinned its carrier thread could not have served more requests than there are carriers, so the server would never have answered. A one-off run of the same check with the scheduler limited to two carrier threads (`-Djdk.virtualThreadScheduler.parallelism=2 -Djdk.virtualThreadScheduler.maxPoolSize=2`) served 16 concurrent requests too; the committed test does not set those options. This was verified for the facade, the Ollama provider and the JDK's `HttpClient`. The other providers were not tested. The OpenAI and Anthropic providers use their vendor SDKs, and this page makes no claim about them. ## Interrupting a call Interrupt the thread that is blocked in a call and the call returns a failed result whose error is a `CancelledError`, with the thread's interrupt flag still set, so your own code (and any executor) still sees the interruption. It behaves the same on a platform thread and on a virtual thread. ```java LlmResult result = client.complete("Long question"); if (result.isFailure() && result.getError().error() instanceof CancelledError) { // the thread was interrupted; its interrupt flag is still set } ``` `cancel(true)` on the future of an `*Async` call interrupts the thread running it, which cancels the provider call in the same way; the future knows that thread, so this works on any executor, a `ForkJoinPool` included, and the executor's thread is handed back with its interrupt flag cleared. Completing the future yourself (`orTimeout`, `completeOnTimeout`) interrupts the call too. `Future.cancel(true)` on a task you submitted yourself interrupts the call as well, on an executor whose `cancel(true)` interrupts. The Spring Boot starter's `completeAsync` is `JLlmClient.completeAsync` on its `llm4sTaskExecutor`. ### `InterruptedException` is never thrown, so Java cannot catch it Neither `JLlmClient.complete` nor `JAgent.run` throws `InterruptedException`. An interrupt comes back as the `CancelledError` result above, with the flag set, and an `InterruptedException` that a custom `LLMClient` lets escape is turned into the same `CancelledError`, with the flag restored. The methods therefore declare no checked exception, and the Java compiler rejects `catch (InterruptedException e)` around either call: ``` error: exception InterruptedException is never thrown in body of corresponding try statement ``` (This message was produced with JDK 21; the test checks the declared exception list, which is what makes the compiler behave this way.) Test the result instead of catching an exception the call never throws: ```java LlmResult result = client.complete("hi"); if (result.isFailure() && result.getError().error() instanceof CancelledError) { // interrupted while blocked: Thread.currentThread().isInterrupted() is still true, so a // loop or an executor further up the stack sees the interruption too return null; } return result.getOrNull(); ``` The flag is still set when the result comes back, so there is nothing to restore; clear it with `Thread.interrupted()` only if you mean to carry on. The test runs this snippet against a call interrupted on a real provider's request path and against a custom client that throws `InterruptedException`, and checks the flag in both cases. ### Interrupting the caller cancels an agent turn All four blocking `JAgent` calls - `run`, `continueConversation`, `resume` and `recover` - cancel the turn they are waiting on when the thread blocked in them is interrupted (since [#1330](https://github.com/llm4s/llm4s/issues/1330); before, they stopped only the wait and the turn carried on in the background). The call returns a failed result whose error is a `CancelledError`, with the interrupt flag still set; the turn's model call is interrupted, and the conversation's thread is left for `recover`. The call returns once the cancelled turn has ended, so `recover` can follow at once. It waits up to 5 seconds for the turn to end (with tracing, delivering the ended turn's last trace events can add to that): a provider that ignores the interrupt for longer is logged at WARN and left to finish on its own, and until it does the thread is busy (`ThreadBusy`). A thread that is already interrupted when it calls one of them starts no turn. To stop a turn from Java, interrupt the thread that called it - for example `cancel(true)` on the future of `runAsync`, `continueConversationAsync`, `resumeAsync` or `recoverAsync`, which ends the turn as an interrupted blocking call does. A turn that had already begun committing its outcome when the interrupt came cannot be cancelled: the call returns that outcome - a completed or suspended turn - with the interrupt flag still set. `run(query)` starts a conversation whose id a failed result does not carry, so a turn of it that fails or is cancelled is forgotten once it has ended. A conversation you named, for `stream` say, is forgotten with `forget(threadId)`, which needs no result. To cancel a turn without interrupting any thread, start it with `JAgent.stream`, `streamResume` or `streamRecover` and call `cancel()` on the returned `AgentStream`. `cancel()` stops the turn and leaves the thread for `streamRecover` or `recover`. Like the blocking calls, it waits up to 5 seconds for the turn to end; if the turn has not ended by then (a provider that ignores the interrupt), it logs a WARN and returns, leaving that turn to finish on its own. Kotlin matches Java: every `AgentKt` suspend function that runs a turn - `run`, `continueConversation`, `resume` and `recover` - cancels the turn when the calling coroutine is cancelled (see [Suspended turns from Java and Kotlin](agents/#suspended-turns-from-java-and-kotlin)), waiting up to 5 seconds for the turn to end through `AgentStream.cancel()`, and the one-shot `run(query)` forgets the thread of a turn that failed or was cancelled once it has ended; a conversation you named, for `stream` say, is forgotten with the suspend `forget(threadId)`, which throws `LLMException` for a thread whose turn is still running. One difference remains, as coroutine cancellation requires: a Kotlin cancellation that loses the race to a turn's commit still throws, though the turn's result is committed. `ThreadingModelSpec` checks `run`; `JAgentPendingSpec` checks `continueConversation`, `resume`, `recover` and an already-interrupted caller; `JAgentStreamSpec` checks `AgentStream.cancel()` and its 5-second bound; the blocking calls' 5-second bound, the commit that beats a cancel and the forgotten one-shot and `runMultiTurn` threads are checked by `AgentRunCancellationSpec` in `llm4s-agent`, and the Kotlin functions - their bound and `run`'s forgotten thread included - by `AgentKtStreamTest`, `AgentKtPendingTest` and `AgentKtTest`. ## Timeouts A call that gets no answer ends with a `TimeoutError`. The limit is set by each provider client: two minutes for a non-streaming call in the Ollama ([`OllamaClient.scala`](https://github.com/llm4s/llm4s/blob/main/modules/ollama/src/main/scala/org/llm4s/llmconnect/provider/OllamaClient.scala)), Gemini and Vertex AI clients and in the OpenAI-compatible clients, which allow five minutes for a streaming call ([`OpenAICompatibleClient.scala`](https://github.com/llm4s/llm4s/blob/main/modules/openai-compatible/src/main/scala/org/llm4s/llmconnect/provider/OpenAICompatibleClient.scala)). Against a server that never answered, the Ollama client returned `TimeoutError: request timed out after 120 seconds`. That run took two minutes, so no test pins it. The OpenAI and Anthropic limits come from their SDKs and were not checked. The limit cannot be configured in this release; [#712](https://github.com/llm4s/llm4s/issues/712) tracks making it configurable. ## Shutting down `JLlmClient` implements `AutoCloseable` and `close()` closes the provider client, so use it in a try-with-resources block. It also shuts down the client's own executor of `*Async` calls, interrupting a call still running; an executor you supplied with `withAsyncExecutor` is yours to shut down. The agent's run and task threads are virtual and so are daemon threads, and its graph writer thread is a daemon thread ([`GraphRuntime.scala`](https://github.com/llm4s/llm4s/blob/main/modules/agent/src/main/scala/org/llm4s/agent/graph/GraphRuntime.scala)). In a one-off check on JDK 21, a call through the Ollama provider left only daemon threads (the JDK `HttpClient`'s selector and workers), and none were left after `close()`. No test pins that, because the set of threads in a shared test JVM is not predictable. Forgetting to close a client therefore should not keep the JVM from exiting, but close it anyway. --- Source: https://llm4s.org/guide/observability/ # Monitoring LLM4S Applications Observability and monitoring for LLM4S applications in production, covering latency, provider reliability, token usage, and cost visibility. --- ## Overview Monitoring LLM applications differs from traditional service monitoring. Key concerns include: - **Latency variability** - LLM calls range from milliseconds to minutes depending on model and prompt size - **Provider reliability** - External API dependencies with rate limits and outages - **Token usage & cost** - Direct correlation between usage and spend - **Trace debugging** - Understanding multi-turn conversations and tool executions - **Quality signals** - Beyond uptime: response relevance, hallucinations, guardrail triggers LLM4S provides tracing infrastructure through multiple backends. Production monitoring typically combines tracing with your existing logging and alerting stack. --- ## Logging in Production LLM4S logs through the SLF4J API and does not bring a logging backend: add one to your application, such as `libraryDependencies += "ch.qos.logback" % "logback-classic" % "1.5.34"`. Without one, SLF4J logs nothing and prints a single warning. Configure the backend for structured JSON output in production environments. ### Logback Configuration ```xml ``` ### What Gets Logged At `INFO` level, LLM4S logs: - Client initialization and shutdown - Tracing backend connection status - Configuration validation results At `DEBUG` level (not recommended for production): - Request/response payloads - Token counts per request - Tracing event details ### Capturing Provider Requests and Responses To see the exact request body a client sent and the exact body that came back, opt in to [Provider Exchange Logging](provider-exchange-logging): each completed call is handed to a sink, and a ready-made sink appends JSON Lines to a file. It is off by default, and what it writes contains your prompts and the model's answers, so read its privacy notes before enabling it. --- ## Tracing for Observability LLM4S supports five tracing backends: | Mode | Use Case | Configuration | Module | |------|----------|---------------|--------| | `langfuse` | Production LLM observability | `TRACING_MODE=langfuse` | `llm4s-observability` | | `opentelemetry` | Integration with existing APM | `TRACING_MODE=opentelemetry` | `llm4s-observability-otel` | | `console` (default) | Local development/debugging; metadata only unless `llm4s.tracing.console.captureContent = true` | `TRACING_MODE=console` | `llm4s-core` | | `noop` | Disabled | `TRACING_MODE=noop` | `llm4s-core` | | `collector` | In-process queryable store | Programmatic (see below) | `llm4s-observability` | What each mode records, and which settings keep personal data out of traces, is in [Data protection](../data-protection#logging-and-tracing-defaults). All backends implement the `Tracing` trait and can be composed with `TracingComposer.combine()`. `llm4s-core` holds the contract - `Tracing`, `TraceEvent`, `TracingComposer`, `TracingMode`, the `TracingBackend` SPI - and builds only `console` and `noop` itself. Every other mode is a `TracingMode.Named` (`TracingMode.Named("langfuse")`, `TracingMode.Named("opentelemetry")`) served by a backend its module registers, so adding the dependency is all it takes: (not compile-checked) ```scala libraryDependencies ++= Seq( "org.llm4s" %% "llm4s-observability" % llm4sVersion, // Langfuse, trace collector/store, CostTracker "org.llm4s" %% "llm4s-observability-otel" % llm4sVersion // OpenTelemetry ) ``` On `0.4.1` and earlier, Langfuse, the collector and `CostTracker` ship inside `llm4s-core`. ### Configuration Core reads only `llm4s.tracing.mode`, and hands the selected mode's block to its backend as `TracingSettings.extras`. Each block, with its variable bindings, ships in the `reference.conf` of the module that reads it - `langfuse` in `llm4s-observability`, `opentelemetry` in `llm4s-observability-otel` - so the keys and variables below are unchanged, and apply once the module is on the classpath: ```hocon llm4s { tracing { mode = ${?TRACING_MODE} langfuse { url = ${?LANGFUSE_URL} publicKey = ${?LANGFUSE_PUBLIC_KEY} secretKey = ${?LANGFUSE_SECRET_KEY} } opentelemetry { serviceName = ${?OTEL_SERVICE_NAME} endpoint = ${?OTEL_EXPORTER_OTLP_ENDPOINT} captureContent = false # ${?OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT} } } } ``` ### OpenTelemetry Integration For teams with existing APM infrastructure (Jaeger, Grafana Tempo, Datadog), the `opentelemetry` mode (alias `otel`) exports traces via OTLP: ```bash TRACING_MODE=opentelemetry OTEL_SERVICE_NAME=my-llm-service OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 ``` This requires the `llm4s-observability-otel` module: (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-observability-otel" % llm4sVersion ``` The module registers itself for `opentelemetry` through a `META-INF/services` entry, so the dependency is all it takes. Its backend builds an `OpenTelemetryConfig` from the `llm4s.tracing.opentelemetry` block with `OpenTelemetryConfig.fromExtras`. #### GenAI semantic conventions Model calls follow the [OpenTelemetry GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/), version `1.37.0` (`OpenTelemetryTracing.SemconvVersion`, also the tracer's schema URL). The GenAI conventions are still in Development status, so an attribute name may change when llm4s moves to a later version. A plain or streamed completion is traced by wrapping the client in `TracingLLMClient`; an `EmbeddingClient` with a tracer traces its own calls: (not compile-checked) ```scala import org.llm4s.trace.TracingLLMClient val traced = TracingLLMClient(client, tracing, providerConfig) // request model and server.address from the config val embeddings = EmbeddingClient.from("openai", embeddingConfig).map(_.withTracing(tracing)) ``` | Span | Kind | Attributes | |------|------|------------| | `chat ` (a completion, plain or streamed) | `CLIENT` | `gen_ai.operation.name` = `chat`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.response.model`, `gen_ai.response.id`, `gen_ai.response.finish_reasons` (streamed), `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`, `gen_ai.request.temperature`, `gen_ai.request.top_p`, `gen_ai.request.max_tokens` (when set), `gen_ai.request.presence_penalty`, `gen_ai.request.frequency_penalty`, `server.address`, `llm4s.model_call.tool_calls`, `llm4s.model_call.streamed`; with `captureContent`, `gen_ai.input.messages` and `gen_ai.output.messages` | | `embeddings ` | `CLIENT` | `gen_ai.operation.name` = `embeddings`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`, `operation` (the client's label), `input_count` | | `chat ` / `embeddings ` of a failed call | `CLIENT`, status `ERROR` (the error message, redacted) | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `error.type` (the `LLMError`'s type, e.g. `RateLimitError`) | | `chat ` (an agent's model call) | `CLIENT` | `gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.agent.name`, `gen_ai.conversation.id`, `gen_ai.usage.*`, `llm4s.model_call.attempt`, `llm4s.model_call.tool_calls`, `cost.usd` | | `invoke_agent ` | `INTERNAL` | `gen_ai.operation.name`, `gen_ai.provider.name` (the run's first model call's), `gen_ai.agent.name`, `gen_ai.conversation.id`, `gen_ai.usage.*`, thread, run, status | | `execute_tool ` | `INTERNAL` | `gen_ai.operation.name`, `gen_ai.tool.name`, `gen_ai.tool.call.id`; with `captureContent` (for `ToolExecuted`), `tool.input` and `tool.output` | `gen_ai.provider.name` is the conventions' well-known value where there is one (`gemini` is `gcp.gemini`, `vertexai` `gcp.vertex_ai`, `bedrock` `aws.bedrock`, `azure` `azure.ai.openai`, `watsonx` `ibm.watsonx.ai`, `mistral` `mistral_ai`, `xai` `x_ai`), else the llm4s provider id (`openai`, `anthropic`, `ollama`, ...). A client reports its provider through `LLMClient.providerId`. **Message content is opt-in.** Prompts and completions often hold personal or confidential data, so by default no span carries them, nor an agent's query or a tool's input and output. Set `llm4s.tracing.opentelemetry.captureContent = true` (or `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true`) to record them as `gen_ai.input.messages` / `gen_ai.output.messages`, in the conventions' JSON form; every piece of text passes through llm4s' secret redaction first and is cut to 1000 characters. `TokenUsageRecorded` remains an `INTERNAL` span `Token Usage - `: it records usage, not a call. GenAI metrics (call duration and token usage) are not spans: [`llm4s-observability-micrometer`](micrometer) records them. ### Adding a Tracing Backend A backend is a `TracingBackend` - the extension point `llm4s-observability` and `llm4s-observability-otel` use - so adding one needs no change to llm4s: (not compile-checked) ```scala import org.llm4s.error.ConfigurationError import org.llm4s.llmconnect.config.TracingSettings import org.llm4s.trace.{ Tracing, TracingMode } import org.llm4s.trace.spi.TracingBackend import org.llm4s.types.Result // A class with a public no-arg constructor - not an object: ServiceLoader instantiates it final class DatadogTracingBackend extends TracingBackend: val mode: TracingMode = TracingMode.Named("datadog") def create(settings: TracingSettings): Result[Tracing] = settings.extras.get("apiKey") match case Some(key) => Right(new DatadogTracing(key, settings.extras.getOrElse("site", "datadoghq.com"))) case None => Left(ConfigurationError("llm4s.tracing.datadog.apiKey is not set")) ``` The backend's settings live under `llm4s.tracing.`, and it reads them from `settings.extras`: that block for the selected mode, flattened to strings keyed by path within it (`site`, `tags.team`). Only the selected mode's block is read. Ship the defaults and variable bindings in the backend module's own `reference.conf`, which HOCON merges with the application's: ```hocon llm4s.tracing.datadog { site = "datadoghq.com" site = ${?DD_SITE} apiKey = ${?DD_API_KEY} } ``` Declare it in `src/main/resources/META-INF/services/org.llm4s.trace.spi.TracingBackend`: ``` com.example.DatadogTracingBackend ``` `TRACING_MODE=datadog` then selects it. `Tracing.fromSettings(settings)` returns a `ConfigurationError` when no backend serves the configured mode, and the backend's own error when `create` fails; `Tracing.create(settings)` logs either and falls back to `NoOpTracing`. Where services files do not survive packaging (some shaded jars), register the backend explicitly: `Tracing.fromSettings(settings, TracingBackends.of(new DatadogTracingBackend))`. --- ## In-Process Trace Collection `TraceCollectorTracing` + `InMemoryTraceStore` provide a fully queryable trace store that runs entirely within the JVM — no external service required. They ship in `llm4s-observability` (`org.llm4s.trace`, `org.llm4s.trace.store`, `org.llm4s.trace.model`). Recorded spans can be filtered, paginated, and serialized to JSON. This is the primary backend for **unit testing** and for **in-process analytics** (latency breakdowns, cache hit rates, token cost per span kind). ### Quick Start (not compile-checked) ```scala import org.llm4s.trace._ import org.llm4s.trace.store._ import org.llm4s.trace.model._ val store = InMemoryTraceStore() // apply returns Result[TraceCollectorTracing]; InMemoryTraceStore never fails val tracer = TraceCollectorTracing(store).getOrElse(sys.error("tracing init failed")) // give the tracer to the agent: every run it makes is traced val result = for { agent <- Agent.builder("assistant", client).withTools(tools).withTracing(tracer).build() result <- agent.run("query") } yield result // retrieve all spans; an agent's runs are recorded as `graph.*` custom events, // spans named `custom:graph.run_started`, `custom:graph.task_completed`, ... val spans = store.getSpans(tracer.traceId) ``` ### Querying Traces `TraceStore.queryTraces` accepts a `TraceQuery` with optional filters and cursor-based pagination. All filters combine with AND semantics. (not compile-checked) ```scala import java.time.Instant // recent traces val page = store.queryTraces( TraceQuery.withTimeRange(Instant.now.minusSeconds(3600), Instant.now) ) // failed traces only val errors = store.queryTraces(TraceQuery.withStatus(SpanStatus.Error(""))).traces // by metadata tag (e.g. experiment grouping) val traceIds = store.searchByMetadata("experiment", "v2") // combined filter with pagination val q = TraceQuery( startTimeFrom = Some(Instant.now.minusSeconds(3600)), status = Some(SpanStatus.Error("")), limit = 10 ) val first = store.queryTraces(q) if (first.hasNext) { val second = store.queryTraces(q.copy(cursor = first.nextCursor)) } ``` ### Span Analytics Every `Span` carries `startTime`, `endTime`, `kind`, `status`, and typed `attributes`, making it straightforward to compute aggregates without an external system. (not compile-checked) ```scala val allSpans = store.getSpans(tracer.traceId) // total milliseconds spent in LLM calls val llmMs = allSpans .filter(_.kind == SpanKind.LlmCall) .flatMap(s => s.endTime.map(e => e.toEpochMilli - s.startTime.toEpochMilli)) .sum // prompt tokens across the run val promptTokens = allSpans .filter(_.kind == SpanKind.LlmCall) .flatMap(_.attributes.get("prompt_tokens").flatMap(_.asLong)) .sum // semantic cache hit rate val cacheSpans = allSpans.filter(_.kind == SpanKind.Cache) val hitRate = if (cacheSpans.isEmpty) 0.0 else cacheSpans.count( _.attributes.get("hit").flatMap(_.asBoolean).contains(true) ).toDouble / cacheSpans.size ``` ### Deterministic Agent Testing Wire `TraceCollectorTracing` in ScalaTest specs to assert on recorded spans without mocking external services or parsing console output. (not compile-checked) ```scala import org.llm4s.trace.model.SpanKind import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import org.scalatest.BeforeAndAfterEach class AgentBehaviourSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach { val store = InMemoryTraceStore() val tracer = TraceCollectorTracing(store).getOrElse(fail("tracing init failed")) override def afterEach(): Unit = store.clear() // built once per spec, with a scripted client and the tracer def agent = Agent.builder("assistant", client).withTools(tools).withTracing(tracer).build() .getOrElse(fail("agent build failed")) /** One span per graph task the agent's `call-tool` node completed: one per tool call. */ def toolCalls = store.getSpans(tracer.traceId).filter { s => s.name == "custom:graph.task_completed" && s.attributes.get("nodeId").flatMap(_.asString).exists(_.endsWith("/call-tool")) } "agent" should "call a tool exactly once" in { agent.run("what is 6 * 7?") shouldBe a[Right[_, _]] toolCalls should have size 1 } "agent" should "record no failed task on a valid query" in { agent.run("hello") shouldBe a[Right[_, _]] store.getSpans(tracer.traceId).map(_.name) should not contain "custom:graph.task_failed" } } ``` ### Composing with External Backends `TracingComposer.combine()` fans events out to multiple backends simultaneously. Use this to keep a local in-process snapshot while also forwarding to Langfuse or OpenTelemetry. (not compile-checked) ```scala val store = InMemoryTraceStore() val collector = TraceCollectorTracing(store).getOrElse(sys.error("tracing init failed")) // LangfuseConfigLoader reads llm4s.tracing.langfuse whatever TRACING_MODE selects val langfuse = LangfuseTracing.from(LangfuseConfigLoader.default().getOrElse(sys.error("bad langfuse config"))) val tracer = TracingComposer.combine(collector, langfuse) val agent = Agent.builder("assistant", client).withTools(tools).withTracing(tracer).build() agent.flatMap(_.run("query")) // local span queries still work val graphSpans = store.getSpans(collector.traceId).filter(_.name.startsWith("custom:graph.")) ``` ### Span JSON Round-Trip `TraceModelJson` serialises and deserialises every span type losslessly via `ujson`. Use this to write spans to disk, ship them to a custom HTTP endpoint, or reload them for offline analysis. (not compile-checked) ```scala import org.llm4s.trace.model.TraceModelJson._ val json = span.toJson // ujson.Value val parsed = TraceModelJson.parseSpan(json) // Result[Span] parsed match { case Right(s) => println(s.name) case Left(error) => println(s"Parse error: ${error.field} — ${error.reason}") } ``` --- ## Langfuse Monitoring Workflow Langfuse provides purpose-built LLM observability. When configured, LLM4S automatically traces: ### What Gets Captured | Event Type | Description | Data Included | |------------|-------------|---------------| | **Traces** | Top-level request lifecycle | Query, final output, duration | | **Generations** | Each LLM call | Model, prompt, completion, tokens, latency | | **Spans** | Tool executions, retrieval ops | Tool name, input/output, duration | | **Events** | Custom markers | User-defined metadata | ### Trace Structure Example A typical RAG query produces this hierarchy: ``` Trace: "RAG Query Processing" ├── Span: "Document Retrieval" (200ms) │ └── Event: "Retrieved 5 documents" ├── Generation: "Context Synthesis" (1,200ms) │ ├── Model: gpt-4o │ ├── Prompt tokens: 1,234 │ └── Completion tokens: 456 └── Event: "Response Delivered" ``` ### What an agent run sends An agent built with `Agent.builder(...).withTracing(tracing)` sends, for each run: - `graph.*` custom events for the runtime's own events (run, task and checkpoint lifecycle), and `agent.*` custom events for the agent's durable events (`agent.model_call_completed`, `agent.tool_executed`, `agent.handed_off`, `agent.guardrail_blocked`), which carry no message content; - a `TokenUsageRecorded` for each model call that reports usage; - one `TraceEvent.AgentRunEnded` when the run ends: thread id, run id, the active agent, a status (`completed`, `suspended`, `step_limit_reached`, `blocked:`, `cancelled`, `timed_out`, `failed`), this turn's messages (from its user message on; empty when blocked, cancelled, timed out or failed) and the run's own `UsageSummary` - the model calls this run made, summed from its `agent.model_call_completed` events, not the thread's total, so per-run figures add up. A run that crashes without a terminal event is traced as `ErrorOccurred`, with a WARN, and has no `AgentRunEnded`; it is traced as soon as the run's last event has been, with no fixed delay. Message content reaches tracing only in `AgentRunEnded.messages`; a blocked turn's content is not in it. (The kernel's own `TracingSubscriber`, used for graphs that are not agents, names agent events `graph.custom`; `withTracing` on an agent names them `agent.*`.) Every one of these events is placed in one trace (`TraceEvent.span`, design 4.58): the run is a span, each event a span under it - a tool call's spanning its duration - and a subagent's run is a span under the `task` tool call that started it, whichever machine runs it. A run continues an upstream trace when given its context: (not compile-checked) ```scala import org.llm4s.trace.TraceContext val upstream = TraceContext.parse(request.header("traceparent")).toOption agent.run(threadId, query, RunConfig().withTraceContext(upstream), Nil) ``` The run's span then takes that context as its remote parent and keeps its trace id. A `resume` or `recover` given no context continues the trace its work started in, which the thread's checkpoints record. | Backend | What `AgentRunEnded` shows | |---------|----------------------------| | Langfuse | One trace per top-level run (trace id = the run's 32-hex trace id, the OpenTelemetry one; `run_id` in its metadata), grouped in a session per thread (session id = thread id); input is the first user message, output the last assistant message; metadata holds the agent, status and usage. The run is an `invoke_agent ` observation over its model calls (generations `chat `), tool calls (spans `execute_tool `), other events and one span per message; a subagent's run creates no trace and nests under the `execute_tool task` that started it | | OpenTelemetry | An `INTERNAL` span `invoke_agent ` with `gen_ai.operation.name`, `gen_ai.agent.name`, `gen_ai.conversation.id`, thread, run, status (`ERROR` when failed, cancelled or timed out), message count, `gen_ai.usage.*` totals and, for a subagent's run, `llm4s.parent.thread_id` / `llm4s.parent.run_id`; it is the parent of the run's `chat ` (`CLIENT`, per-call `gen_ai.usage.*`) and `execute_tool ` (`INTERNAL`) spans and its other events, and a subagent's `invoke_agent` is under its `execute_tool task`. No attribute carries message content | | `TraceCollector` | A `SpanKind.AgentCall` span with the same attributes, token totals as `input_tokens`/`output_tokens`, and no cost; like every event of the run it is stored in the run's own trace, with its span id, parent and start | | Console | A multi-line "Agent Run Ended" block: agent, status, thread, run, message count, token totals | ### Environment Setup Langfuse is served by `llm4s-observability`: (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-observability" % llm4sVersion ``` ```bash TRACING_MODE=langfuse LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= LANGFUSE_URL=https://cloud.langfuse.com # or self-hosted URL ``` Both keys are required. With either missing, `Tracing.fromSettings` returns a `ConfigurationError` naming what is unset - e.g. `llm4s.tracing.langfuse.publicKey (LANGFUSE_PUBLIC_KEY)` - and `Tracing.create` logs that error and falls back to `NoOpTracing`. Without `llm4s-observability` on the classpath, the error names the artifact to add instead. To read the Langfuse settings whatever `TRACING_MODE` selects (for example to combine Langfuse with another tracer), use `LangfuseConfigLoader.default()` from `org.llm4s.config`. ### Tracing API LLM4S exposes a `Tracing` trait for custom instrumentation: (not compile-checked) ```scala import org.llm4s.trace.{Tracing, TraceEvent} import scala.concurrent.duration.* // Trace custom events tracing.traceEvent(TraceEvent.CustomEvent("cache_hit", ujson.Obj("key" -> "query_123"))) // Trace token usage explicitly tracing.traceTokenUsage(usage, model = "gpt-4o", operation = "completion") // Trace a finished agent run (an agent built withTracing does this itself, once per run) tracing.traceEvent(TraceEvent.AgentRunEnded(threadId, runId, agent, status, messages, usage)) // Trace costs tracing.traceCost( costUsd = 0.0234, model = "gpt-4o", operation = "completion", tokenCount = 1500, costType = "total" ) // Trace RAG operations tracing.traceRAGOperation( operation = "search", duration = 150.millis, embeddingTokens = Some(128), llmPromptTokens = None, llmCompletionTokens = None, totalCostUsd = Some(0.0001) ) ``` --- ## Metrics Tracing records what happened inside one request. Metrics count what happens across all of them: requests, tokens, cost, errors and latency, per provider and model. They are cheap enough to alert on, and they answer "is a provider failing right now?" faster than a trace search. The contract is `MetricsCollector` in `llm4s-core` (`@Stable`): `observeRequest`, `addTokens` and `recordCost`, plus calls for retries, circuit-breaker transitions, errors and image generation, and the generic `increment`, `observe` and `set` through which other modules record metrics of their own ([below](#agent-runtime-metrics)). Every chat client calls the collector it was built with, and collectors are expected not to throw. The default is `MetricsCollector.noop`, so metrics cost nothing until you pass one in. `llm4s-observability-prometheus` provides the implementation, `PrometheusMetrics`, and the HTTP endpoint that serves it, `PrometheusEndpoint`. If your application already has a Micrometer `MeterRegistry` (Spring Boot Actuator, Quarkus), use `llm4s-observability-micrometer` instead: its `MicrometerMetrics` records the same series into that registry, and the Spring Boot starter wires it with no code. See [Micrometer metrics](micrometer). ### Add the module (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-observability-prometheus" % llm4sVersion ``` On `0.4.1` and earlier this code ships inside `llm4s-core`; the [migration note](../../reference/migration#slice-6-llm4s-observability-prometheus---prometheus-leaves-core) has the details. Package names are unchanged. ### Turn it on from configuration Metrics are off unless asked for, because turning them on starts an HTTP server. To turn them on: ```hocon llm4s { metrics { enabled = true prometheus { enabled = true port = 9090 } } } ``` The module's `reference.conf` ships `enabled = false`, `prometheus.enabled = true` and `port = 9090`, so setting only `enabled = true` is enough: the Prometheus backend is used on port `9090`. `MetricsConfigLoader.default()` reads the block and returns `Result[(MetricsCollector, Option[PrometheusEndpoint])]`: | `llm4s.metrics` | Collector | Endpoint | |---|---|---| | `enabled = false` (the default) | `MetricsCollector.noop` | none, no server is started | | `enabled = true`, `prometheus.enabled = false` | `MetricsCollector.noop` | none | | `enabled = true` | `PrometheusMetrics` | started on `prometheus.port`; `Left(ConfigurationError)` if the port is taken | Pass the collector to the client when you build it: (not compile-checked) ```scala import org.llm4s.config.{Llm4sConfig, MetricsConfigLoader} import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService val application = for { providerConfig <- Llm4sConfig.defaultProvider() registryService <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registryService configured <- MetricsConfigLoader.default() (metrics, endpoint) = configured client <- LLMConnect.getClient(providerConfig, metrics).left.map { error => endpoint.foreach(_.stop()) // release the server if client creation fails error } } yield (client, endpoint) ``` On success, retain the returned endpoint alongside the client and call `endpoint.foreach(_.stop())` during application shutdown. The collector and endpoint from the loader share the same registry. `LlmClientOptions(metrics = ...)` is the same thing through the options overload of `LLMConnect.getClient`. The endpoint that `MetricsConfigLoader` starts stays up until you call `PrometheusEndpoint.stop()`, which is safe to call twice. ### Or build the pieces yourself If you already own a `PrometheusRegistry`, create the collector on it and start the endpoint on the same registry: (not compile-checked) ```scala import io.prometheus.metrics.model.registry.PrometheusRegistry import org.llm4s.metrics.{ PrometheusEndpoint, PrometheusMetrics } val registry = new PrometheusRegistry() val metrics = new PrometheusMetrics(registry) val endpoint: Option[PrometheusEndpoint] = PrometheusEndpoint.start(9090, registry) match { // Result[PrometheusEndpoint]; serves /metrics case Right(ep) => Some(ep) case Left(error) => // the port could not be bound: report this as a startup failure rather than // running with metrics configured but nothing to scrape None } // retain the handle: endpoint.foreach(_.stop()) at application shutdown (safe to call twice) ``` ### What a call records - **Every call**, successful or not, records one request with its latency and its outcome. A failure is classified into an error kind (`rate_limit`, `timeout`, `authentication`, `network`, `validation`, `service_error`, `execution_error`, `cancelled` or `unknown`) from the error the client returned. - **Tokens and cost are recorded only on success**, and only when the completion carries them: tokens when the provider reported usage, cost when the client attached an estimated cost. A call with neither adds nothing to those series. - **A streamed call is recorded once, when the stream has finished**, with the total latency, not as it goes. - **Embedding calls are not recorded, and no RAG- or reranking-specific series exists.** The chat calls that RAG and the LLM reranker make through an instrumented client are recorded as ordinary requests - `RAG`'s answer generation and `LLMReranker`'s scoring both go through the client's `complete` - so that traffic does show up in the request series under the provider and model. The Cohere reranker calls Cohere's API directly, not through an LLM client, and is not recorded. Image generation is recorded through `InstrumentedImageGenerationClient`, which wraps an image client. ### The series | Series | Type | Labels | |---|---|---| | `llm4s_requests_total` | counter | `provider`, `model`, `status` (`success` or `error_`) | | `llm4s_tokens_total` | counter | `provider`, `model`, `type` (`input` or `output`) | | `llm4s_cost_usd_total` | counter | `provider`, `model` | | `llm4s_errors_total` | counter | `provider`, `error_type` | | `llm4s_request_duration_seconds` | histogram | `provider`, `model`; buckets 0.1, 0.5, 1, 2, 5, 10, 30, 60 and 120 seconds | | `llm4s_image_generations_total` | counter | `provider`, `model`, `operation`, `status` | | `llm4s_images_generated_total` | counter | `provider`, `model` | | `llm4s_image_generation_duration_seconds` | histogram | `provider`, `model`, `operation` | | `llm4s_image_generation_cost_usd_total` | counter | `provider`, `model` | | `llm4s_image_generation_errors_total` | counter | `provider`, `model`, `operation`, `error_type` | After one successful call and one rate-limited call to `gpt-4o`, the chat series on `/metrics` look like this (comment lines left out; the second call took 90 seconds): ```text llm4s_cost_usd_total{model="gpt-4o",provider="openai"} 0.0021 llm4s_errors_total{error_type="rate_limit",provider="openai"} 1.0 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="0.1"} 0 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="0.5"} 0 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="1.0"} 0 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="2.0"} 1 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="5.0"} 1 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="10.0"} 1 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="30.0"} 1 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="60.0"} 1 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="120.0"} 2 llm4s_request_duration_seconds_bucket{model="gpt-4o",provider="openai",le="+Inf"} 2 llm4s_request_duration_seconds_count{model="gpt-4o",provider="openai"} 2 llm4s_request_duration_seconds_sum{model="gpt-4o",provider="openai"} 91.5 llm4s_requests_total{model="gpt-4o",provider="openai",status="error_rate_limit"} 1.0 llm4s_requests_total{model="gpt-4o",provider="openai",status="success"} 1.0 llm4s_tokens_total{model="gpt-4o",provider="openai",type="input"} 120.0 llm4s_tokens_total{model="gpt-4o",provider="openai",type="output"} 30.0 ``` ### Scrape it and alert on it Point Prometheus at the endpoint: ```yaml scrape_configs: - job_name: 'llm4s' static_configs: - targets: ['localhost:9090'] ``` Queries to start from (standard PromQL over the series above): ```promql # Requests per second, by provider sum by (provider) (rate(llm4s_requests_total[5m])) # Share of requests that failed sum(rate(llm4s_requests_total{status!="success"}[5m])) / sum(rate(llm4s_requests_total[5m])) # 95th percentile latency, by provider histogram_quantile(0.95, sum by (le, provider) (rate(llm4s_request_duration_seconds_bucket[5m]))) # Rate-limit errors per second sum(rate(llm4s_errors_total{error_type="rate_limit"}[5m])) # Estimated spend over the last hour, by model sum by (model) (increase(llm4s_cost_usd_total[1h])) ``` ### Send metrics to more than one place `MetricsCollector.compose` forwards every `MetricsCollector` method, the two image-generation ones (`observeImageGeneration` and `recordImageGenerationCost`) included, to each collector it is given, so Prometheus can run next to `CostTracker` (in `llm4s-observability`) or a collector of your own, and a composed collector handed to `InstrumentedImageGenerationClient` records image metrics in every child. A collector that throws does not stop the others: (not compile-checked) ```scala import org.llm4s.metrics.MetricsCollector val both = MetricsCollector.compose(metrics, myCollector) // LLMConnect.getClient(providerConfig, both) ``` ### Agent runtime metrics The agent runtime (`llm4s-agent`) reports runs, claims, commits, child tasks and its run queue through the same collector, as generic `Metric`s (`MetricsCollector.increment`, `observe` and `set`), so `PrometheusMetrics` and `MicrometerMetrics` export them without depending on `llm4s-agent` ([design 4.59](https://github.com/llm4s/llm4s/blob/main/docs/design/typed-agent-runtime/4.59-stage-5-slice-o2-run-claim-queue-and-child-task-metrics.md)). Two things record them: a `MeteredCheckpointer` wrapped around the store every runtime and worker of the process shares, and an `AgentWorker` given the collector. (not compile-checked) ```scala import org.llm4s.agent.metrics.MeteredCheckpointer val metered = MeteredCheckpointer(store.checkpointer, metrics) // every GraphRuntime and the worker use `metered` // AgentWorker(queue, metered, agents, metrics = metrics) ``` | Metric (Prometheus series) | Type | Labels | Recorded by | |---|---|---|---| | `llm4s_agent_runs_started_total` | counter | `graph`, `trigger` (`start`, `resume`, `recover`) | checkpointer | | `llm4s_agent_runs_ended_total` | counter | `graph`, `status` (`completed`, `suspended`, `failed`, `cancelled`, `timed_out`) | checkpointer | | `llm4s_agent_run_duration_seconds` | histogram | `graph`, `status` | checkpointer | | `llm4s_agent_claims_total` | counter | `result` (`acquired`, `taken_over`, `busy`, `failed`) | checkpointer | | `llm4s_agent_claim_renewals_total` | counter | `result` (`renewed`, `lost`, `failed`) | checkpointer | | `llm4s_agent_commit_duration_seconds` | histogram | `result` (`ok`, `conflict`, `claim_lost`, `failed`) | checkpointer | | `llm4s_agent_tasks_started_total` | counter | `agent` | checkpointer | | `llm4s_agent_tasks_ended_total` | counter | `agent`, `status` | checkpointer | | `llm4s_agent_task_duration_seconds` | histogram | `agent`, `status` | checkpointer | | `llm4s_agent_queue_depth` | gauge | `agent`, `state` (`waiting`, `leased`) | worker | | `llm4s_agent_queue_oldest_seconds` | gauge | `agent` | worker | | `llm4s_agent_queue_wait_seconds` | histogram | `agent` | worker | | `llm4s_agent_queue_attempts_total` | counter | `agent`, `result` (`recorded`, `exhausted`, `thread_deleted`) | worker | | `llm4s_agent_queue_plans_total` | counter | `agent`, `plan` (`start`, `recover`, `close`) | worker | | `llm4s_agent_queue_deferrals_total` | counter | `agent`, `reason` (`busy`, `unrunnable`) | worker | | `llm4s_agent_queue_lease_expiries_total` | counter | `agent` | worker | | `llm4s_agent_queue_lease_lapses_total` | counter | `agent` | worker | | `llm4s_agent_queue_lease_losses_total` | counter | `agent`, `reason` (`refused`, `unrenewed`) | worker | | `llm4s_agent_queue_closed_total` | counter | `agent`, `outcome`, `reason` | worker | | `llm4s_agent_sweep_passes_total` | counter | `result` (`done`, `skipped`, `failed`) | worker | | `llm4s_agent_sweep_threads_examined_total`, `..._tombstones_reclaimed_total`, `..._entries_reclaimed_total` | counter | none | worker | | `llm4s_agent_worker_drained_total` | counter | `result` (`finished`, `cancelled`, `abandoned`) | worker | Dead letters are `llm4s_agent_queue_closed_total{outcome="failed",reason="attempts_exhausted"}`. The queue gauges are store-wide: every worker of a fleet reports the same numbers, every `llm4s.agent.worker.metrics.queueDepthEvery` (15 seconds), so aggregate them with `max`. `graph` and `agent` are ids your code fixes; no thread, run, entry or tenant id is ever a label, and no metric carries message content. Every backend caps a label at `MetricsCollector.MaxLabelValues` (256) distinct values and records the rest as `other`. ### Limits - **Retries, circuit-breaker transitions and `ReliableClient`'s error events have no series of their own.** `ReliableClient` takes its own `collector` argument and reports those three kinds of event to it, but `PrometheusMetrics` does not implement them. The request series still see every attempt: `ReliableClient` calls the wrapped client once per attempt, and a metrics-enabled client records each of those calls, so a call that fails twice and then succeeds adds three requests - two `error_*`, one `success` - and three latency observations. Request and error rates are per attempt, not per logical call. - **The latency buckets are fixed** (0.1 to 120 seconds); there is no way to configure them. - **Counters live in the process.** A restart starts them from zero, which is what `rate()` and `increase()` expect. - **The endpoint has no authentication and no TLS**: llm4s starts it with only a port and a registry. Keep it off the public network and let your network policy decide who can scrape it. - **Labels are provider and model.** The number of series grows with the number of distinct models you call, not with traffic. --- ## Health Checks ### Startup Validation Use `client.validate()` during application startup to fail fast on misconfiguration: (not compile-checked) ```scala val clientResult = for { config <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(config) _ <- client.validate() } yield client clientResult match { case Left(error) => logger.error(s"LLM client validation failed: $error") System.exit(1) case Right(client) => logger.info("LLM client ready") } ``` ### Readiness Probes For Kubernetes deployments, include LLM connectivity in readiness checks: (not compile-checked) ```scala // Integrate with your framework's health check mechanism def isLLMReady(): Boolean = client.validate().isRight ``` ### Liveness vs Readiness - **Liveness**: Application process is healthy (standard JVM checks) - **Readiness**: Can serve LLM requests (includes `client.validate()`) Separating these prevents pod restarts during temporary provider outages. --- ## Provider Reliability Signals ### Failures to Monitor | Signal | Meaning | Action | |--------|---------|--------| | `429 Too Many Requests` | Rate limited | Back off, check quotas | | `503 Service Unavailable` | Provider outage | Failover or queue | | Connection timeout | Network issue | Retry with backoff | | Response timeout | Slow generation | Increase timeout or reduce prompt | ### Logging Provider Failures (not compile-checked) ```scala import org.llm4s.error._ def logProviderFailure(error: LLMError): Unit = error match { case rle: RateLimitError => logger.warn(s"Rate limited: ${rle.message}, retry after: ${rle.retryAfter.fold("unknown")(_.toString)}") case ServiceError(message, httpStatus, provider, requestId, retryAfter) => logger.error(s"Provider API error [$httpStatus]: $message") case NetworkError(message, cause, endpoint) => logger.error( s"Network failure from $endpoint: $message" + cause.map(c => s" - ${c.getMessage}").getOrElse("") ) case timeout: TimeoutError => logger.warn(s"Request timeout: ${timeout.message}") case other => logger.error(s"LLM error: $other") } ``` ### Alerting Thresholds Recommended alert conditions for LLM services: | Metric | Warning | Critical | |--------|---------|----------| | Error rate | > 1% | > 5% | | P95 latency | > 10s | > 30s | | Rate limit events | > 10/min | > 50/min | | Daily token spend | > 80% budget | > 95% budget | --- ## Token & Cost Monitoring ### Token Usage Tracking LLM4S traces token usage through the tracing infrastructure. With Langfuse, usage appears automatically in the dashboard. For programmatic access: (not compile-checked) ```scala completion.usage match { case Some(usage) => logger.info( s"Tokens - prompt: ${usage.promptTokens}, " + s"completion: ${usage.completionTokens}, " + s"total: ${usage.totalTokens}" ) // Trace for aggregation tracing.traceTokenUsage(usage, model, "completion") case None => logger.warn("Token usage not available from provider") } ``` ### Cost Estimation Pricing varies by provider and changes over time. For cost tracking: - **Langfuse** automatically calculates costs when model pricing is configured in its dashboard - **Provider billing APIs** offer authoritative usage and spend data - **Manual tracking** can use `tracing.traceCost()` with your own pricing logic (not compile-checked) ```scala // Track cost through tracing (pricing logic is your responsibility) tracing.traceCost( costUsd = estimatedCost, model = "gpt-4o", operation = "completion", tokenCount = usage.totalTokens, costType = "total" ) ``` ### Budget Awareness Use context budget methods to prevent runaway costs: (not compile-checked) ```scala import org.llm4s.agent.{Agent, ContextWindowConfig} import org.llm4s.agent.graph.middleware.ContextWindowMiddleware import org.llm4s.types.HeadroomPercent // Get available tokens considering model limits and safety margin val budget = client.getContextBudget(HeadroomPercent.Standard) val config = ContextWindowConfig(maxTokens = Some(budget)) // ContextWindowMiddleware uses a default token counter (words * 1.3) // or accepts a custom tokenCounter function for more accurate estimation val agent = Agent.builder("assistant", client) .withMiddleware(new ContextWindowMiddleware(config)) .build() ``` --- ## Production Checklist Before deploying, verify monitoring coverage: ### Tracing - [ ] `TRACING_MODE` set to `langfuse` or `opentelemetry` - [ ] Tracing credentials configured and tested - [ ] Traces visible in dashboard (test with sample request) ### Metrics - [ ] `llm4s.metrics.enabled = true`, and Prometheus scrapes the `/metrics` endpoint - [ ] The endpoint is reachable only from your monitoring network - [ ] Error-ratio and latency alerts use `llm4s_requests_total` and `llm4s_request_duration_seconds` ### Logging - [ ] Structured JSON logging enabled - [ ] Log levels appropriate (`INFO` for `org.llm4s`) - [ ] Logs shipping to aggregation system ### Alerting - [ ] Error rate alerts configured - [ ] Latency (P95/P99) alerts configured - [ ] Rate limit event alerts configured - [ ] Cost/budget alerts configured ### Health - [ ] `client.validate()` called on startup - [ ] Readiness probe includes LLM connectivity - [ ] Graceful degradation for provider outages --- ## Known Limitations Current monitoring limitations in LLM4S: - **Prometheus metrics cover LLM and image-generation calls, not traces** (see [Metrics](#metrics)) - `PrometheusMetrics` (`llm4s-observability-prometheus`) records requests, tokens, cost, errors and latency through `MetricsCollector`; for span-level data, read `InMemoryTraceStore` and push to your Prometheus client, or implement a custom `TraceStore` that writes directly to a metrics registry - **No automatic cost aggregation** - Langfuse provides this via its dashboard; for in-process aggregation, sum `cost_usd` attributes from `SpanKind.Rag` and `SpanKind.LlmCall` spans in `InMemoryTraceStore` - **No real-time streaming metrics** - Streaming completions are traced on completion, not in-flight - **Guardrail metrics require custom tracing** - Add `traceEvent` calls in guardrail implementations; the resulting spans are then queryable via `InMemoryTraceStore` These are tracked in the [Production Readiness Roadmap](../../reference/roadmap). --- ## Related Documentation - [Provider Exchange Logging](provider-exchange-logging) - Capture raw provider requests and responses for debugging - [In-Process Tracing Use Cases](enhanced-tracing-use-cases.md) - Full catalogue of `TraceCollectorTracing` + `InMemoryTraceStore` scenarios - [Langfuse Workflow Patterns](../../langfuse-workflow-patterns.md) - Detailed trace event sequences - [Configuration Guide](../../getting-started/configuration) - Complete configuration reference - [Roadmap](../../reference/roadmap) - Planned observability improvements --- Source: https://llm4s.org/guide/observability/provider-exchange-logging # Provider Exchange Logging Capture the raw request and response of each provider call, to a file or to your own code, to debug what was sent and what came back. --- > **Privacy warning.** An exchange holds the request and response bodies as the provider saw them: your > prompts, the conversation history, tool arguments and results, any documents you retrieved into the > prompt, and the model's answers. They routinely contain personal data and business data. The file sink > redacts credentials by pattern, on a best-effort basis, and cannot recognise personal data; **a sink > you write receives the bodies untouched.** Treat everything this feature writes as sensitive: keep it > off unless you are debugging, protect and delete what it writes, and do not share it unreviewed. > See [Redaction and truncation](#redaction-and-truncation) and [Using it safely](#using-it-safely). ## What it is Exchange logging records, for each call a client makes to a provider, one `ProviderExchange`: the JSON body it sent, the body it got back (or the error), the timing and the outcome. It answers "what exactly did the provider receive, and what exactly did it say?", which tracing does not: tracing records what an agent did, not the wire. It is **off by default** and **opt in per client**. You turn it on with a `ProviderExchangeLogging` setting carried by `LlmClientOptions`, and the setting names a `ProviderExchangeSink` that receives each completed exchange. LLM4S ships a sink that appends JSON Lines to a file; you can write your own. ## What is recorded A `ProviderExchange` has these fields: | Field | Type | What it holds | |-------|------|---------------| | `exchangeId` | `String` | A random UUID for this exchange | | `provider` | `String` | The client's provider name, such as `deepseek` | | `model` | `Option[String]` | The model the client is **configured** with, not the model the response reports (a router can serve another) | | `requestId`, `correlationId` | `Option[String]` | Reserved: no built-in client sets them, so they are always `None` | | `startedAt`, `completedAt` | `Instant` | When the call began and when it finished | | `duration` | `FiniteDuration` | The time between the two | | `outcome` | `ProviderExchangeOutcome` | `Success` or `Error`; see the limitations for `Cancelled` | | `requestBody` | `String` | The JSON body sent to the provider | | `responseBody` | `Option[String]` | The body received: for a streamed call, the raw server-sent events; for an HTTP error, the error body; `None` when no response arrived | | `errorMessage` | `Option[String]` | The error's message, when the call failed | Headers are not recorded, so an API key that travels in a header is not in an exchange. These clients record both `complete` and `streamComplete`: OpenAI, Azure and Requesty (`llm4s-openai`); Anthropic; Gemini and Vertex AI; Ollama; the OpenAI-compatible clients (DeepSeek, Z.ai, OpenRouter, Mistral, Cohere and the generic `openai-compatible` provider); Bedrock; and watsonx. Embedding, image and speech clients do not record exchanges. ## Turning it on ### From configuration ```hocon llm4s { exchangeLogging { enabled = true dir = "exchange-logs" } } ``` The two keys have variables bound in `reference.conf`: `LLM4S_EXCHANGE_LOGGING_ENABLED` and `LLM4S_EXCHANGE_LOGGING_DIR`. `enabled` defaults to `false`, and there is no default directory: `enabled = true` without a `dir` is a `ConfigurationError` ("Provider exchange logging is enabled but llm4s.exchangeLogging.dir is missing"). **Nothing reads this section on its own.** `LLMConnect.getClient(config)` uses `LlmClientOptions.default`, which has logging disabled. Read the setting with `Llm4sConfig.exchangeLogging()` and hand it to the client: (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.{ LLMClient, LLMConnect, LlmClientOptions } import org.llm4s.model.ModelRegistryService import org.llm4s.types.Result def fromConfiguration(): Result[LLMClient] = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry exchangeLogging <- Llm4sConfig.exchangeLogging() client <- LLMConnect.getClient(providerConfig, LlmClientOptions(exchangeLogging = exchangeLogging)) } yield client ``` When logging is enabled, `Llm4sConfig.exchangeLogging()` creates the directory if it is missing and the file for this run immediately, so a directory that cannot be written is reported at start-up, not at the first call. ### From code `ProviderExchangeSink.createRunScopedJsonl` creates the file sink; `ProviderExchangeLogging.enabled` wraps a sink in the setting: (not compile-checked) ```scala import java.nio.file.Path import org.llm4s.llmconnect.{ ProviderExchangeLogging, ProviderExchangeSink } import org.llm4s.types.Result def jsonlLogging(dir: Path): Result[ProviderExchangeLogging] = ProviderExchangeSink.createRunScopedJsonl(dir).map(ProviderExchangeLogging.enabled) ``` Any sink works the same way: wrap it in `ProviderExchangeLogging.enabled` and give it to the client in `LlmClientOptions`: (not compile-checked) ```scala import org.llm4s.llmconnect.{ LLMClient, LLMConnect, LlmClientOptions, ProviderExchangeLogging, ProviderExchangeSink } import org.llm4s.llmconnect.config.ProviderConfig import org.llm4s.model.ModelRegistryService import org.llm4s.types.Result def clientFor(providerConfig: ProviderConfig, sink: ProviderExchangeSink)(using ModelRegistryService ): Result[LLMClient] = LLMConnect.getClient(providerConfig, LlmClientOptions(exchangeLogging = ProviderExchangeLogging.enabled(sink))) ``` ## The JSON Lines file `createRunScopedJsonl` creates one file for the run, named from the time it was created: `provider-exchanges-2026-10-07T10-20-30Z.jsonl`. If that name is taken (two runs in the same second), it adds a number: `...Z-2.jsonl`, `...Z-3.jsonl`. It creates missing directories, and returns a `ConfigurationError` if the path is a file. The file exists, empty, as soon as the sink is created; each exchange is appended as one line when the call completes. A line, from a real run against a local test server that answered 401: ```json {"exchange_id":"bd1e7d34-3c95-44e5-a9be-428bf3ca85f1","provider":"deepseek","model":"deepseek-chat","request_id":"","correlation_id":"","started_at":"2026-10-07T10:27:19.668236Z","completed_at":"2026-10-07T10:27:19.676649Z","duration_ms":"8","outcome":"Error","request_body":"{\"model\":\"deepseek-chat\",\"messages\":[{\"role\":\"user\",\"content\":\"will fail\"}],\"temperature\":0.7,\"top_p\":1}","response_body":"{\"error\":\"Unauthorized\"}","error_message":"Authentication failed for deepseek: Unauthorized. Check that the key is this provider's, and that its project or organisation has access to the model. See https://llm4s.org/getting-started/troubleshooting#authentication-failed"} ``` The keys are always these twelve, in this order. A value that is absent is an empty string, not a missing key or `null`. `duration_ms` is a JSON **string** (`"8"`), so read it as one. `request_body`, `response_body` and `error_message` are the text of the exchange, escaped into a JSON string, after the redaction and truncation below. The sink does not rotate or limit the file, and does not set permissions on it. ## Redaction and truncation The file sink changes `request_body`, `response_body` and `error_message` before writing them: - **Redaction.** It replaces, with `[REDACTED]`: an `Authorization`, `Proxy-Authorization`, `Cookie` or `Set-Cookie` header, from after the `:` and the whitespace after it to the end of its line (for a cookie, every name and value of it; see below for where a cookie header's value ends inside a string), bearer tokens, URL query parameters with sensitive names (`api_key`, `token`, `password` and similar), JSON fields with sensitive names (also when the JSON sits inside a prompt or response string with escaped quotes, `\"api_key\": \"...\"`, or is single-quoted, or is cut off before its closing quote, as a truncated payload leaves it), numbers under such a key (including exponent forms such as `1e10`, and inside a string too; the number is written back as the string `"[REDACTED]"`, so the JSON still parses, or as `'[REDACTED]'` under a single-quoted key), a double-quoted value under a single-quoted key, as Python's `repr` writes a string that holds a `'` (`{'password': "it's"}`, and `\"it's\"` inside a string), where it reads as a value of the dict (followed by `,` and the next key, by `}`, or cut off), arrays and objects under such a key (`{"token": ["..."]}`, `{"credentials": {"user": "...", "pass": "..."}}`: every leaf under the key is replaced, however deep - strings, numbers and, outside a string, bare words - also inside a string and when the key or the leaves are single-quoted, as a Python dict is, `{'token': ['...']}`; the brackets, the keys of nested objects, `true`, `false` and `null` are kept, so the JSON still parses and keeps its shape; inside a string a single-quoted container ends where the string does, so a `'token': [` that a message merely mentions does not take the fields after it, nor the prose after an apostrophe in it (`it's`) unless escaped JSON (`\"`) follows it, and an unclosed `'password': '` there ends where the string ends rather than taking the rest of the document - but only where no `'` that could close it follows anywhere in the input, and not where that end would leave the value empty (`'password': '",`); otherwise it runs to the next `'`, or to the end of the input, as it does outside a string, so a credential that holds a `"` (`'Qx"]]9secret'`) is redacted whole; a field under a single-quoted key is also read in the input as it was given, so that a double-quoted value before it that runs over its key - a `"password": "` that a Python dict's message only mentions, `{'note': 'see "password": " here', 'apiKey': [...]}` - cannot hide it: what that reading replaces is added where nothing else was replaced, and every other replacement is made as before; outside any string, the bare words after an unclosed `'token': [` are replaced to the end of the input, since nothing tells them from leaves; under a single-quoted key a single-quoted leaf that stands where a value does is replaced whatever it holds, a connection string `'postgres://u:...@h/db'` or a `'k=v'` token included, and so is a `\"`-quoted leaf there, `\"{'token': [\\\"...\\\"]}\"`, when its own `\"` closes it where a value does; any other `"` under a single-quoted key inside a string ends the container, since it may be the end of a string inside it; a Python literal is also read as Python writes it, wherever a dict key `'password':` or a keyword argument `token=[...]` / `password='...'` stands: each string, number and word of the value is replaced, its quotes, brackets and the dict's keys kept, a string ending only at its own quote written the way the opening one is - so `{'password': "x'y", 1: 2}` and a value before any next key or none, as a dict cut off after its `,` has it, a `pytest` assertion diff whose lines start with `E` and its `+`/`-` markers, a notebook's `text/plain` line array in an `.ipynb` file, `str(dict)` inside a `repr` (`{'body': '{\'password\': "it\'s..."}'}`), Python's `\'` inside a JSON string (`"{'password': 'a\\'b\"c'}"`), `Settings(token=['a', 'b'])`, a call under a sensitive key (`Settings(token=SecretStr('...'))`, `api_key=module.Secret(value='...')`) and a `\"` in an earlier leaf are redacted; a `"` of a credential in a single-quoted string is replaced with it (`'[REDACTED]'`), while a `"` that delimits a JSON string, at any depth, is never replaced, so the JSON still parses, and what this reading replaces is added only where no other replacement is), `key=value` pairs and quoted `KEY="value"` / `KEY='value'` assignments outside a query string (for example `password=...`, `spring.datasource.password=...`, `PASSWORD="..."`), also with the quotes escaped by backslashes, as a logfmt line inside a JSON string writes them (`{"message": "login password=\"...\" ok"}`, `\'...\'`, and `\\\"...\\\"` one string deeper: the content is replaced and the escaped quotes kept, so the JSON still parses; an escaped quote never closed ends where the enclosing string or the line does; a value in quotes behind an even run of backslashes, `password=\\'p&ss=...\\'` inside a JSON string, is redacted up to the same quote written the same way, `&word=` included), also with `=` and `&` written as the JSON escapes Gson's default HTML-safe output uses (`password\u003d...\u0026user\u003d...`) and in a log written into a JSON string, where the key follows the escape of a line break or a tab (`...\ntoken\u003d...`, `\r\nAuthorization=...`, also one string deeper); a value that holds a literal `&` with no other pair after it is redacted on both sides of the `&` (`password=p&ssW0rd` becomes `password=[REDACTED]&[REDACTED]`, while `password=abc&user=bob` keeps `user=bob`); a `Bearer` or `Basic` scheme word right before a pair does not hide the pair's value (`Bearer password=...` becomes `[REDACTED]=[REDACTED]`), `key: value` header lines (for example `x-api-key: ...`), and strings shaped like known provider API keys (for example `sk-` keys). A quoted value is redacted whole, escaped quotes included. A key is sensitive when its whole name, lower-cased with `_` and `-` dropped, is `token`, `authorization`, `proxyauthorization`, `credential(s)`, `cookie(s)` or `setcookie`, or ends in `apikey`, `secret`, `password`, `passwd`, `privatekey`, `accesstoken`, `refreshtoken`, `idtoken`, `authtoken`, `sessiontoken`, `securitytoken` or `bearertoken`, or the plural `secrets`, `passwords` or `apikeys`: `client_secret`, `x-api-key`, `api-key`, `x-goog-api-key`, `x-auth-token`, `secrets`, `api_keys`, `db_passwords`, `x-amz-security-token`, `refresh_token`, `db_password`, `Proxy-Authorization`, `Cookie` and `Set-Cookie` are redacted, as a header line, a JSON field or header map (escaped too) and a `key=value` pair; `max_tokens`, `prompt_tokens`, `token_count`, `next_page_token`, `cookie_policy` and `cookie_consent` are not, because the match is never on a substring; nor is `tokens`, a count in every provider exchange. URL query parameters are also matched against a second, substring list (`key`, `token`, `secret`, `password`, `auth` and similar), so `?x_api_key_id=` is redacted too, and a third, matched whole, of parameters that carry a short-lived credential: `sig`, `signature`, `X-Amz-Signature`, `X-Goog-Signature`, `code` (an OAuth authorization code), `session`, `sessionid` and `jsessionid` (also as the matrix parameter `;jsessionid=`, and in a URL's fragment, `#code=...`, and after an HTML-escaped separator, `?sv=...&sig=...`); a field `"code": 400` is left, since only a query parameter's key is read so. The password of a URL's userinfo is redacted wherever the URL is (`https://alice:[REDACTED]@gw.example.com/v1`, also with JSON's `\/`), every URL of a list joined by `,` or `;` included; the user and the host are kept. A query value written right before JSON (`?token=...,{"api_key": "..."}`, also with the quotes escaped) does not hide the field after it. - **Where a cookie header's value ends.** Outside any string (a raw request dump, a log line), at the end of its line, quotes, apostrophes and backslashes included: `Cookie: sid="abc"; x=y` becomes `Cookie: [REDACTED]`. Inside a string that opened on the same line - a JSON string, a header in a JSON array, an HTTP exchange logged inside JSON, a Python repr - earlier: at the escape of a line break (`\r`, `\n`, `\u000a`, `\u000d`, and `\\r`, `\\n` before the next header in JSON inside a JSON string) or, in a double-quoted string, at the unescaped `"` that ends the string. So `{"log":"GET / HTTP/1.1\r\nCookie: sid=...\r\nAccept: */*"}` becomes `{"log":"GET / HTTP/1.1\r\nCookie: [REDACTED]\r\nAccept: */*"}`, and `["Cookie: sid=...", "Accept: */*"]` keeps its second element; the JSON still parses. An escaped quote (`sid=\"abc\"`) is part of the value. Ending at the quote is a heuristic for well-formed JSON strings, where the first unescaped `"` ends the string: when that quote is not followed by what follows the end of a JSON string (`}`, `]`, or `,` and a value, then the end of the line; or `:` and a value, where the header is an object's key), the line is not well-formed JSON - a stray quote earlier on it, `size 5" Cookie: sid="abc"; x=y`, or quoted cookie values in a log line - and the value runs to the end of the line, quotes and all, as outside a string: `msg="Cookie: theme="dark" sid=abc"` becomes `msg="Cookie: [REDACTED]`. Cookie headers are redacted last, after every other pattern, so they only add to what is redacted: a cookie value that takes a quote of a malformed line cannot change how the field patterns read the quotes around it, and a `password="..."` or `{'password': "..."}` field later on that line or the next is still redacted. An `Authorization` or `Proxy-Authorization` header's value always runs to the end of its line, inside a string too, taking the rest of that string and the fields after it on the same line: a Digest credential holds quoted strings and commas, so a quote is no sure end of it. - **Truncation.** It keeps the first 1000 characters, after redaction, and appends `... [truncated, N chars omitted]` with the count it dropped. Redact the full text before you cut it, as this sink and every other llm4s call site do: redaction of text that is already cut off is weaker. An unclosed single-quoted value inside a raw (unescaped) double-quoted string, holding a `"` followed by what follows a string's end and with no `'` after it that could close it, ends at that `"`, so the part after it is shown - `msg="{'password': 'Qx"]]9secretPW` cut off there becomes `msg="{'password': '[REDACTED]"]]9secretPW`. A closing `'` with a letter or digit on both sides (`'Qx"]]9SECRETPW'it"`) reads as an apostrophe, not as the end of the value, so the same happens there. Both are limits you should know about: - **The file is not a full record.** A prompt or an answer longer than 1000 characters is cut, so a long conversation or a long completion is only partly in the file. For complete bodies, write your own sink. - **Redaction recognises patterns, not meaning.** It does not look for personal data: an email address or a phone number is written as it is. Text you put in a prompt is inside a JSON string in the request body, and a credential there is not guaranteed to match a pattern: a secret that is not under a key (a key pasted into a prompt as prose), and JSON escaped twice (JSON inside a string inside a string, `\\\"api_key\\\": ...`) are not redacted, by decision ([#1576](https://github.com/llm4s/llm4s/issues/1576)): a pattern cannot know what a secret is, and only one level of escaping is recognised. A URL query value that holds a `'` before `,`, `)`, `;` or `:` (`?token=ab',cd`) is redacted only up to that quote, since it cannot be told from the quote that ends a string, as in `fetch('...?token=ab')`. Do not rely on redaction to make the file safe to share. A sink you write is **not** redacted or truncated: it is handed the exchange exactly as the client built it, including the full bodies. If your sink writes bodies anywhere, redaction is your job. ## Writing your own sink A sink implements one method: (not compile-checked) ```scala trait ProviderExchangeSink: def record(exchange: ProviderExchange): Unit ``` The client calls it once the call has finished, inline, on the thread that made the call (the tests behind this guide check that for the OpenAI-compatible clients), so keep it fast. If it throws, the client discards the exception: the call still succeeds, and nothing logs the failure, so a broken sink is silent. Catch and report your own errors. A sink that keeps the metadata and never touches a body is the safest way to see what your application sends and how long it takes: (not compile-checked) ```scala import org.llm4s.llmconnect.{ ProviderExchange, ProviderExchangeSink } final class MetadataOnlySink(out: String => Unit) extends ProviderExchangeSink: def record(exchange: ProviderExchange): Unit = out(s"${exchange.provider} ${exchange.model.getOrElse("-")} ${exchange.outcome} ${exchange.duration.toMillis}ms") ``` With `out = println`, a call prints `deepseek deepseek-chat Success 180ms`. To keep bodies only when something went wrong, wrap another sink: (not compile-checked) ```scala import org.llm4s.llmconnect.{ ProviderExchange, ProviderExchangeOutcome, ProviderExchangeSink } final class ErrorsOnlySink(delegate: ProviderExchangeSink) extends ProviderExchangeSink: def record(exchange: ProviderExchange): Unit = if exchange.outcome == ProviderExchangeOutcome.Error then delegate.record(exchange) ``` ## Using it safely - **Keep it off in production** unless you are chasing a specific problem, and turn it off again after. For production monitoring use [tracing and metrics](index), which record what an agent did and what it cost rather than the provider's wire format. - **Protect the directory.** The sink creates the file with the process's default permissions; restrict access to the directory yourself, and delete old files, because nothing rotates or expires them. - **Keep the logs out of version control.** The samples' default directory, `.devlog/logs/provider-exchanges`, is not in the repository's `.gitignore`; if you enable logging while running the samples, add the directory to yours before you commit. - **Mind the cost.** The file sink opens, appends and closes the file for each exchange, on the thread making the call. ## Known limitations - The file sink truncates bodies to 1000 characters; see above. - Redaction is pattern-based and best effort; it does not detect personal data. A secret that is not under a key, and JSON escaped twice, are not redacted. - `requestId` and `correlationId` are never filled by the built-in clients. - `ProviderExchangeOutcome` declares `Cancelled`, but the recorder produces only `Success` and `Error`: a cancelled call is recorded as an `Error` carrying the cancellation message. - A sink that throws fails silently. - Embedding, image and speech clients do not record exchanges. - Nothing enables logging from configuration automatically; you pass the setting to the client. - `duration_ms` in the file is a string. ## Related - [Monitoring](index): tracing, logging and health checks for production - [Writing a Provider](../writing-a-provider): how a provider records its exchanges with `ProviderExchangeRecorder` - [Configuration](../../getting-started/configuration): the full configuration reference --- Source: https://llm4s.org/guide/observability/micrometer # Micrometer metrics Record LLM4S request, token, cost and error metrics in a Micrometer `MeterRegistry` you already have, so a Spring Boot, Quarkus or plain JVM service shows them next to its own metrics. --- ## When to use it Use `llm4s-observability-micrometer` when your application already ships metrics through Micrometer (to Prometheus, Datadog, CloudWatch, OpenTelemetry or anything else a registry exports to) and you want the LLM metrics in the same place. If you only want a Prometheus endpoint and have no Micrometer registry, use [`llm4s-observability-prometheus`](index) instead. The two modules are independent: neither depends on the other, and `llm4s-core` depends on neither. Core defines only the `MetricsCollector` contract that every client accepts. ## Install (not compile-checked) ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-observability-micrometer" % llm4sVersion ``` It depends on `llm4s-core` and `micrometer-core`. The Micrometer jars from 1.14 to 1.17 are class-file version 52, which any JDK can load, so the module does not raise the JDK you need. The module is built against Micrometer 1.17 and uses only the stable instrument API (`Counter`, `Timer`, `MeterRegistry`); your application's own Micrometer version is the one that runs. ## Quick start Create a `MicrometerMetrics` over your registry and give it to the client: (not compile-checked) ```scala import io.micrometer.core.instrument.MeterRegistry import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.metrics.MicrometerMetrics import org.llm4s.model.ModelRegistryService def clientWithMetrics(meterRegistry: MeterRegistry) = for { providerConfig <- Llm4sConfig.defaultProvider() registryService <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registryService metrics = new MicrometerMetrics(meterRegistry) client <- LLMConnect.getClient(providerConfig, metrics) } yield client ``` Every call through that client now records into `meterRegistry`. To read a value back, for example in a test: (not compile-checked) ```scala val calls = meterRegistry.find("llm4s.requests").tag("status", "success").counter().count() ``` ## What is recorded | Meter | Type | Tags | Prometheus series | |---|---|---|---| | `llm4s.requests` | counter | `provider`, `model`, `status` | `llm4s_requests_total` | | `llm4s.request.duration` | timer | `provider`, `model` | `llm4s_request_duration_seconds` | | `llm4s.tokens` | counter | `provider`, `model`, `type` | `llm4s_tokens_total` | | `llm4s.cost.usd` | counter | `provider`, `model` | `llm4s_cost_usd_total` | | `llm4s.errors` | counter | `provider`, `error_type` | `llm4s_errors_total` | | `llm4s.reliability.errors` | counter | `provider`, `error_type` | `llm4s_reliability_errors_total` | | `llm4s.retries` | counter | `provider` | `llm4s_retries_total` | | `llm4s.circuit.breaker.transitions` | counter | `provider`, `state` | `llm4s_circuit_breaker_transitions_total` | | `llm4s.image.generations` | counter | `provider`, `model`, `operation`, `status` | `llm4s_image_generations_total` | | `llm4s.images.generated` | counter | `provider`, `model` | `llm4s_images_generated_total` | | `llm4s.image.generation.duration` | timer | `provider`, `model`, `operation` | `llm4s_image_generation_duration_seconds` | | `llm4s.image.generation.cost.usd` | counter | `provider`, `model` | `llm4s_image_generation_cost_usd_total` | | `llm4s.image.generation.errors` | counter | `provider`, `model`, `operation`, `error_type` | `llm4s_image_generation_errors_total` | The last column is what a Micrometer `PrometheusMeterRegistry` exposes for the meter. Tag values: - `status` is `success` or `error_`, and `error_type` is ``, where `` is one of `rate_limit`, `timeout`, `authentication`, `network`, `validation`, `service_error`, `execution_error`, `cancelled` or `unknown`. - `type` is `input` or `output`. - `operation` is `generate`, `edit` or `other`. - `state` is `open`, `closed`, `half-open` or `other`. The request timer is published with the same bucket boundaries as the Prometheus module (0.1, 0.5, 1, 2, 5, 10, 30, 60 and 120 seconds, and 1, 2, 5, 10, 30, 60, 120 and 300 seconds for image generation), so `_bucket` series line up. The agent runtime's run, claim, commit, child-task and run-queue metrics (`llm4s.agent.*`) are recorded through the collector's generic `increment`, `observe` and `set` and land in the registry under their own names, with the same series the Prometheus module exposes: see [Agent runtime metrics](index#agent-runtime-metrics). ## Dashboards built for the Prometheus module If you exported `llm4s_*` series with `llm4s-observability-prometheus` and switch to this module with a Micrometer `PrometheusMeterRegistry`, the same queries keep working. This is tested: `MicrometerPrometheusSeriesSpec` sends identical calls to both collectors and compares the series they expose (names, label names, bucket boundaries and values). Two differences: - This module also records retries (`llm4s_retries_total`), circuit-breaker transitions (`llm4s_circuit_breaker_transitions_total`) and `recordError` calls (`llm4s_reliability_errors_total`), which the Prometheus collector leaves as no-ops. - A Micrometer timer also exposes a `_max` series. The Prometheus module has no equivalent. ## Errors and `ReliableClient` `llm4s.errors` counts failed requests and failed image generations, exactly as `llm4s_errors_total` does in the Prometheus module: one increment per call the provider client observed failing. `llm4s.reliability.errors` counts `recordError` calls instead. In llm4s the caller is `ReliableClient`, which reports a call its local rate limit or open circuit rejected, a deadline exceeded, and the final failure once retries are exhausted. Pass the same collector to both layers: (not compile-checked) ```scala val metrics = new MicrometerMetrics(meterRegistry) val client = LLMConnect .getClient(providerConfig, metrics) .map(c => new ReliableClient(c, "openai", ReliabilityConfig.default, Some(metrics))) ``` and a call that fails on all three attempts records three `llm4s.errors` (one per attempt), one `llm4s.reliability.errors` (the final failure) and two `llm4s.retries`. The counters are separate so that neither counts the same failure twice: `llm4s_errors_total` is failed provider calls, `llm4s_reliability_errors_total` is what the caller of `ReliableClient` saw fail. ## Tag cardinality Tags are provider, model and values from a fixed set, so the number of meters stays small. Prompts, user ids and request ids are never tags. Guards apply to values the caller supplies: - A null or blank provider or model is recorded as `unknown` (Micrometer rejects null tag values). - An image operation or circuit-breaker state outside the sets above is recorded as `other`, so a stray value cannot create a new meter. A test sends a thousand distinct values and checks the meter count does not grow. - `provider` and `model` come from your configuration, so they are normally a handful of values. They are capped anyway: one collector records at most 64 distinct providers and 256 distinct models, and records any further value as `other` (logged once at WARN). A value seen before the cap was reached keeps its own series. If you need a tighter bound, add Micrometer's own `MeterFilter.maximumAllowableTags`. ## Failures never reach the caller Like every `MetricsCollector`, no method throws. If the registry rejects a meter, for example because a `MeterFilter` throws, the problem is logged at WARN and the metric is dropped. Negative or non-finite amounts for tokens and cost are ignored instead of corrupting a counter. ## Names and the OpenTelemetry GenAI conventions The OpenTelemetry GenAI semantic conventions define metric names such as `gen_ai.client.operation.duration`. They are still in Development status, and they are changing: the token metric `gen_ai.client.token.usage` has since been replaced by `gen_ai.client.inference.usage.input_tokens` and `gen_ai.client.inference.usage.output_tokens`, and the conventions have moved to their own repository, `open-telemetry/semantic-conventions-genai`. This module therefore keeps the stable `llm4s.*` names above, which match the existing Prometheus series, and does not freeze names that are still moving. The concepts map as follows, and a Micrometer `MeterFilter` can rename a meter if you need the OpenTelemetry names today: | llm4s | OpenTelemetry GenAI | |---|---| | `provider` tag | `gen_ai.provider.name` | | `model` tag | `gen_ai.request.model` | | `llm4s.request.duration` | `gen_ai.client.operation.duration` | | `llm4s.tokens` with `type=input` / `type=output` | `gen_ai.client.inference.usage.input_tokens` / `.output_tokens` | | `error_type` tag | `error.type` | The `provider` tag is the llm4s provider id (`gemini`); OpenTelemetry spans report the conventions' well-known value for it (`gcp.gemini`) where there is one, as [the span tables](index#genai-semantic-conventions) list. ## Spring Boot With `llm4s-spring-boot-starter`, add this module next to `spring-boot-starter-actuator` and there is nothing to write: the starter's `LlmMetricsAutoConfiguration` registers a `MicrometerMetrics` over the application's `MeterRegistry` (the bean `llm4sMetricsCollector`) and builds its client with it, so the meters above are on `/actuator/metrics` and in whatever the registry exports to. The starter declares this module `provided`, like Actuator, so it reaches your classpath only when you add it: ```xml org.llm4s llm4s-observability-micrometer_3 0.5.1 ``` A `MetricsCollector` bean of your own replaces `llm4sMetricsCollector`, and the client reports to every `MetricsCollector` bean in the context. `llm4s.enabled=false` switches it off with the rest of the starter. See [Spring Boot](../spring-boot#metrics). --- Source: https://llm4s.org/guide/rag-query-transformers # RAG Query Transformers Rewrite the user's query before it is embedded and sent to the vector store, improving retrieval quality for vague or conversational questions. ## Overview A RAG pipeline can rewrite the user's query before it is embedded: [`RAGConfig`](https://github.com/llm4s/llm4s/blob/main/modules/rag/src/main/scala/org/llm4s/rag/RAGConfig.scala) accepts a chain of `QueryTransformer`s, and `RAG` applies the chain before retrieval (`QueryTransformer.applyChain`). (not compile-checked) ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMClient import org.llm4s.model.ModelRegistryService import org.llm4s.rag.{ RAG, RAGSearchResult } import org.llm4s.rag.transform.LLMQueryRewriter import org.llm4s.types.Result def search(llmClient: LLMClient, question: String)(using ModelRegistryService): Result[Seq[RAGSearchResult]] = for { embeddingPair <- Llm4sConfig.embeddings() (provider, embeddingCfg) = embeddingPair config = RAG .builder() .withEmbeddings(provider, embeddingCfg.model) .withQueryTransformer(LLMQueryRewriter(llmClient)) rag <- RAG.build(config, _ => Right(embeddingCfg)) results <- rag.query(question) // the rewritten query is embedded and searched } yield results ``` `Llm4sConfig.embeddings()` reads the embedding provider from `llm4s.embeddings`; a `ModelRegistryService` comes from `Llm4sConfig.modelRegistryService()`. ## Built-in transformers All built-in transformers live in `org.llm4s.rag.transform`: | Transformer | Name | What it does | |-------------|------|--------------| | `LLMQueryRewriter(llmClient)` | `llm-query-rewriter` | Asks an LLM to rewrite the query into a precise, search-friendly form | | `LLMQueryRewriter(llmClient, systemPrompt)` | `llm-query-rewriter` | Same, with a custom system prompt | | `IdentityTransformer()` | `identity` | Returns the query unchanged | `LLMQueryRewriter` sends the query to the LLM with `temperature = 0.0` and a default system prompt that instructs the model to return only the rewritten query, preserve the original intent, and expand abbreviations. If the LLM call fails, the error is returned as a `ProcessingError` whose `operation` is `"query-rewrite"` (message `Processing failed during query-rewrite: Failed to rewrite query: …`). `IdentityTransformer` is a pass-through: it is useful for testing pipeline composition without side effects, or as a default placeholder. ## Adding transformers to a RAG pipeline `RAG.builder()` returns a `RAGConfig`, which exposes two methods: - `withQueryTransformer(transformer: QueryTransformer)` — **appends** one transformer to the existing chain - `withQueryTransformers(transformers: Seq[QueryTransformer])` — **replaces** the whole chain (not compile-checked) ```scala import org.llm4s.rag.RAG import org.llm4s.rag.transform.{IdentityTransformer, LLMQueryRewriter} val config = RAG.builder() .withEmbeddings("openai") .withQueryTransformer(LLMQueryRewriter(llmClient)) // appended .withQueryTransformer(IdentityTransformer()) // appended after the rewriter // alternatively, set the whole chain at once: // .withQueryTransformers(Seq(LLMQueryRewriter(llmClient), IdentityTransformer())) ``` ## Chaining and error behaviour Transformers run **sequentially, in the order they were added**, and each transformer receives the output of the previous one: (not compile-checked) ```scala import org.llm4s.error.ProcessingError import org.llm4s.rag.transform.{ IdentityTransformer, QueryTransformer } import org.llm4s.types.Result val upperCase = new QueryTransformer { val name = "upper-case" def transform(query: String): Result[String] = Right(query.toUpperCase) } val failing = new QueryTransformer { val name = "always-fails" def transform(query: String): Result[String] = Left(ProcessingError(name, "no")) } QueryTransformer.applyChain("k8s docs", Seq(upperCase, IdentityTransformer())) // Right("K8S DOCS") QueryTransformer.applyChain("k8s docs", Seq(failing, upperCase)) // Left(ProcessingError), upperCase never runs ``` `QueryTransformer.applyChain` folds the chain over the query with `Result[String]` (`Either[LLMError, String]`) and **short-circuits at the first error**: no further transformer runs, and the failed `Result` propagates to `rag.query`. A successful chain passes the fully transformed string to the embedding step. ## Writing your own transformer A `QueryTransformer` is a trait with two members: (not compile-checked) ```scala trait QueryTransformer { def transform(query: String): Result[String] def name: String } ``` `name` is a human-readable label for the transformer. The pipeline doesn't read it today, so use it in your own logging, or as the `operation` of the errors you return, as below. The example below expands a known abbreviation deterministically — no LLM call involved: (not compile-checked) ```scala import org.llm4s.error.ProcessingError import org.llm4s.rag.transform.QueryTransformer import org.llm4s.types.Result final class AbbreviationExpander(abbreviations: Map[String, String]) extends QueryTransformer { override val name: String = "abbreviation-expander" override def transform(query: String): Result[String] = { val expanded = query.split("\\s+").map { token => abbreviations.getOrElse(token.toLowerCase, token) }.mkString(" ") if (expanded.trim.isEmpty) Left(ProcessingError(name, "Query is empty after expansion")) else Right(expanded) } } ``` Using it (a real call, not just a definition): (not compile-checked) ```scala val expander = new AbbreviationExpander(Map( "k8s" -> "Kubernetes", "pg" -> "PostgreSQL", "fts5" -> "SQLite FTS5" )) // Right("How do I deploy Kubernetes on PostgreSQL clusters?") val result: Result[String] = expander.transform("How do I deploy k8s on pg clusters?") ``` Add it to a pipeline like any built-in transformer: (not compile-checked) ```scala val config = RAG.builder() .withEmbeddings("openai") .withQueryTransformer(expander) // then build it with RAG.build(config, ...) as in the Overview ``` Keep custom transformers deterministic and cheap when possible — they run on every query, so an LLM-backed transformer adds latency to every retrieval. --- Source: https://llm4s.org/guide/pekko # Apache Pekko Integration The `llm4s-pekko` module (Beta) exposes `LLMClient.streamComplete` and an `Agent` run as [Apache Pekko Streams](https://pekko.apache.org/docs/pekko/current/stream/index.html) `Source`s, so LLM output plugs into a Pekko, Pekko HTTP or Play pipeline with the backpressure and cancellation you already use. It is the third streaming bridge, next to [cats-effect](cats-effect) (fs2) and [ZIO](zio) (ZStream), and it has the same shape. ## Dependency (not compile-checked) ```scala // build.sbt libraryDependencies += "org.llm4s" %% "llm4s-pekko" % "" ``` The module depends on `llm4s-core`, `llm4s-agent` and `pekko-stream` 1.x (Apache-2.0). Add a provider module as for any llm4s application. Akka is not supported: its licence is not Apache-2.0, and the module depends only on Pekko. ## LLMClientPekko `LLMClientPekko` wraps an `LLMClient`. It does not manage the client's lifecycle; build the client as the [configuration guide](../getting-started/configuration) describes and close it when you are done. (not compile-checked) ```scala import org.llm4s.llmconnect.LLMClient import org.llm4s.pekko.LLMClientPekko def wrap(client: LLMClient): LLMClientPekko = LLMClientPekko(client) ``` ### Streaming `streamComplete` returns a `Source[StreamedChunk, Future[Done]]` of the provider's chunks, as they arrive: (not compile-checked) ```scala import org.apache.pekko.actor.ActorSystem import org.llm4s.llmconnect.model.{ Conversation, UserMessage } def printAnswer(client: LLMClientPekko)(using system: ActorSystem) = client .streamComplete(Conversation(Seq(UserMessage("Explain monads in one sentence.")))) .runForeach(chunk => print(chunk.content.getOrElse(""))) ``` The provider call starts when the stream is materialized, once per materialization, on a thread of its own: a blocking call never runs on a stream or actor dispatcher thread. - **Backpressure.** The provider calls back on its own thread and cannot be paused, so the stream blocks that thread while `bufferSize` chunks (64 unless you pass another) wait for the consumer. Nothing is dropped, and no more than `bufferSize` chunks are held. - **Cancellation.** Cancelling the stream, or stopping it early with `take`, interrupts the provider's thread, which is how llm4s providers are cancelled: they keep the interrupt and return `Left(CancelledError)`. A provider that swallows the interrupt and keeps calling back is refused at once on every later chunk, never blocked, so its thread ends when its call returns. A kill switch works as well: (not compile-checked) ```scala import org.apache.pekko.stream.KillSwitches import org.apache.pekko.stream.scaladsl.{ Keep, Sink } import org.llm4s.llmconnect.model.StreamedChunk def cancellable(client: LLMClientPekko, conversation: Conversation)(using system: ActorSystem) = client .streamComplete(conversation) .viaMat(KillSwitches.single[StreamedChunk])(Keep.right) .toMat(Sink.foreach(chunk => print(chunk.content.getOrElse(""))))(Keep.both) .run() // later: killSwitch.shutdown() interrupts the provider call ``` - **Errors.** If the call fails part-way, the chunks already received are emitted first, then the stream fails with an `LLMException` that carries the `LLMError`. A `bufferSize` below 1 fails the stream at once. ### complete as a Future (not compile-checked) ```scala import org.llm4s.llmconnect.model.Completion import scala.concurrent.{ ExecutionContext, Future } def complete(client: LLMClientPekko, conversation: Conversation)(using ec: ExecutionContext): Future[Completion] = client.complete(conversation) ``` The call blocks, so pass a blocking `ExecutionContext`, for example `system.dispatchers.lookup(Dispatchers.DefaultBlockingDispatcherId)`. A `Future` cannot be cancelled; use `streamComplete` when the call has to be. A provider error fails the `Future` with an `LLMException`. ## AgentPekko `LLMClientPekko.agent(id)(configure)` builds an `AgentPekko` from `Agent.builder(id, client)` with `configure` applied, and returns a `Result` (a builder that does not build is a `Left`). `AgentPekko(agent)` wraps an agent you built yourself, with its tools, guardrails and handoffs. (not compile-checked) ```scala import org.llm4s.agent.events.AgentEvents import org.llm4s.agent.graph.ThreadId import org.llm4s.pekko.{ AgentPekko, AgentStreamItem } def streamAnswer(agent: AgentPekko)(using system: ActorSystem) = agent .stream(ThreadId("chat-1"), "What is the capital of France?") .runForeach { case AgentStreamItem.Event(AgentEvents.TextDelta(delta)) => print(delta.text) case AgentStreamItem.Done(result) => println(s"\nDone: ${result.answer}") case _ => () } ``` `stream` emits every event of the turn (`AgentStreamItem.Event`), then the result (`AgentStreamItem.Done`). `streamResume` and `streamRecover` do the same for `Agent.resume` and `Agent.recover`. - The turn starts when the stream is materialized, once per materialization. - Cancelling the stream, or stopping it early with `take`, cancels the turn: its model call and tool calls are interrupted, and the thread is left for `recover`. Cancelling returns at once, while the turn is still ending; the stream's materialized `Future[Done]` completes once it has ended and the thread is free (see below). - A slow consumer never holds the run up. It loses live events (text deltas, tool progress) and receives one `StreamEvent.LiveGap` with their count where they were dropped. Durable events are kept, up to 1024 waiting (`Agent.StreamCapacity`); a consumer that falls further behind fails the stream. `bufferSize` (256 unless you pass another) is how many live events wait before the rest are counted. - A refused start (a blank query, a busy thread), a failed turn and a subscription that disconnects fail the stream with an `LLMException`. A turn that commits no terminal event still ends the stream. ### Waiting for a cancelled turn `stream`, `streamResume` and `streamRecover` are `Source[AgentStreamItem, Future[Done]]`. The `Future[Done]` completes once the turn has ended and released its thread, however the stream ended: completed, failed or cancelled. It never fails; a failed turn fails the stream. Pekko stops a stream asynchronously, so a cancelled turn may still be ending when your code moves on, and a `recover` on its thread started then is refused with `GraphError.ThreadBusy`. Keep the `Future[Done]` with `Keep.both` and wait for it before the next call on the thread: (not compile-checked) ```scala import org.apache.pekko.stream.scaladsl.{ Keep, Sink } def firstEventThenRecover(agent: AgentPekko, threadId: ThreadId)(using system: ActorSystem, ec: ExecutionContext) = val (released, firstEvent) = agent.stream(threadId, "Write a long story.").take(1).toMat(Sink.head)(Keep.both).run() for _ <- firstEvent _ <- released // the cancelled turn has ended and its thread is free result <- agent.recover(threadId) yield result ``` A turn whose provider ignores the interrupt keeps the `Future[Done]` pending until its call returns. When the stream completes normally, the `Future[Done]` is already complete. ### Futures `run`, `continueConversation`, `recover` and `resume` return a `Future[AgentResult]`, started and awaited on the `ExecutionContext` you pass: (not compile-checked) ```scala import org.llm4s.agent.AgentResult def ask(agent: AgentPekko, question: String)(using ec: ExecutionContext): Future[AgentResult] = agent.run(question) ``` A `Future` cannot be cancelled. Stream the turn when it has to be cancellable. ## Errors Every `LLMError` reaches you as an `LLMException`, the failure of the stream or the `Future`; its `error` field is the `LLMError`: (not compile-checked) ```scala import org.apache.pekko.stream.scaladsl.Sink import org.llm4s.pekko.LLMException import scala.concurrent.ExecutionContext def answerOrNothing(client: LLMClientPekko, conversation: Conversation)(using system: ActorSystem, ec: ExecutionContext) = client .streamComplete(conversation) .runWith(Sink.seq) .map(chunks => chunks.flatMap(_.content).mkString) .recover { case e: LLMException => s"failed: ${e.error.message}" } ``` An agent turn that fails with a provider error reports it wrapped in a `GraphError.NodeFailed`, as with the other bridges; the provider's error is its `cause`. ## Differences from `AgentIO` and `AgentZ` `AgentPekko` is a thin wrapper over `Agent`, built on the same internals as the fs2 and ZIO bridges, so a turn behaves the same way. What differs is the platform: - Pekko has no resource or layer type, so there is no `LLMClientPekko.resource`: build and close the `LLMClient` yourself. - `run` and the other `Future` methods cannot be cancelled; the streams can. - There is no Java DSL (`javadsl.Source`): the module's API is the Scala DSL. From Java, use [`llm4s-java-api`](java)'s `JAgent.stream`, which delivers the same events to a listener, or convert a `Source` you already have with `asJava()`. - Pekko HTTP routes and Play controllers are not part of the module. Serve a stream by mapping it to your framework's response type, for example `Source[ServerSentEvent, NotUsed]` in Pekko HTTP. --- Source: https://llm4s.org/guide/jev # Jev, TypeSafe's decision model `llm4s-jev` is a client for [Jev](https://docs.typesafe.ai/introduction), TypeSafe's System One model. Jev is **not a chat model**. You send it a *state* and named, typed *questions*, and it answers each question with a typed answer your code can act on directly: the probability of a yes, the selected option with a distribution over the options, or a score along ordered levels. Your code stays in control: it asks narrow questions and decides what to do with the answers. That is why this module has its own client. Jev is not an `LLMClient`, returns no `Completion`, and has no conversation and no streaming. Sending a prompt through a chat client and parsing generated JSON would lose the typed question and answer contract; so would treating Jev as an OpenAI-compatible provider. (not compile-checked) ```scala // same version as llm4s-core libraryDependencies += "org.llm4s" %% "llm4s-jev" % llm4sVersion ``` It brings no dependency beyond `llm4s-core`, and is a Beta module under `modules/providers/jev`. ## Configure it Set your TypeSafe API key in `TYPESAFE_API_KEY`, the variable TypeSafe's own SDKs read. Nothing else is needed: (not compile-checked) ```scala import org.llm4s.config.JevConfigLoader // Result[JevClient]: Left(ConfigurationError) when no key is set val client = JevConfigLoader.default().flatMap(JevClient(_)) ``` `JevConfigLoader` (in `org.llm4s.config`, like LLM4S's other loaders) reads the `llm4s.jev` block of your `application.conf` at the application edge; `JevClient` itself reads no configuration and takes only the `JevConfig` it is given. Every setting has a default: ```hocon llm4s.jev { model = "jev-1.13.0" # pin the answers; the default is the alias jev-latest, which moves when a release ships timeout = 20 seconds # per HTTP attempt; the default is 30 seconds retry { maxRetries = 3 # after the first attempt; defaults: 2, 500 ms, 5 s, 0.25, 30 s backoffInitial = 500 milliseconds backoffMax = 5 seconds jitter = 0.25 budget = 30 seconds } # apiKey = ... # wins over llm4s.credentials.typesafe.apiKey, which TYPESAFE_API_KEY binds # baseUrl = ... # TYPESAFE_BASE_URL; default https://api.typesafe.ai } ``` | Setting | Environment variable | Default | |---|---|---| | `llm4s.credentials.typesafe.apiKey` (or `llm4s.jev.apiKey`) | `TYPESAFE_API_KEY` | none: required | | `llm4s.jev.baseUrl` | `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | | `llm4s.jev.model` | `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | Or build a config in code: (not compile-checked) ```scala val config = JevConfig(apiKey = "tsk-...") .withModel("jev-1.13.0") // pin the answers; the default is the alias jev-latest .withTimeout(20.seconds) .withRetry(JevRetryPolicy(maxRetries = 3)) val client = JevClient(config) // validates the config ``` The key is a bearer token, so the client refuses to send it in the clear: the base URL must be `https`, except for a loopback host (`localhost`, `127.0.0.0/8`, `::1`), which is how a test points the client at a local server. A URL that carries credentials, such as `http://localhost@evil.example/`, is refused. The key never appears in `toString` or in a log line, and an error masks it wherever a server echoed it in one of the forms listed under [Errors](#errors). ## Ask questions A request is a `state` and a map of questions. All the questions are answered against the one state in a single call, so ask everything you might need at once and let your code decide which answers matter. (not compile-checked) ```scala val decision = for { client <- JevClient(config) response <- client.evaluate( JevRequest( "Help! My payouts have been failing for 3 days.", Map( "department" -> JevQuestion.choice( "Which team should handle this?", "billing" -> "Payments, invoicing, refunds", "technical" -> "Bugs, outages, integrations", "sales" -> "Pricing, upgrades, new accounts" ), "urgent" -> JevQuestion.noul("Does this need immediate human attention?"), "frustration" -> JevQuestion.score("How frustrated is the customer?", "Calm", "Frustrated", "Very angry") ) ) ) department <- response.choice("department") urgent <- response.noul("urgent") frustration <- response.score("frustration") } yield (department.choice, department.confidence, urgent.probability, frustration.score) ``` | Question | You give | The answer carries | |---|---|---| | `JevQuestion.Noul` | a yes/no question, optionally what yes and no mean | `probability` of yes, 0 to 1 | | `JevQuestion.Choice` | options, each with an optional description (at most 255) | `choice`, a `probabilities` map and a `confidence` | | `JevQuestion.Score` | 2 to 10 ordered level descriptions | `score` (it can land between levels; one a floating-point rounding error, within 1e-9, outside 0 to the highest level is clamped into range), `levels` with their probabilities, and a `confidence` | The ids (`"department"`, ...) are yours; the answers come back under the same ids and Jev never sees them. `state` and `instructions` can be a string, or JSON structure when a question refers to data: see TypeSafe's [API reference](https://docs.typesafe.ai/api). A request that breaks a documented limit is refused with a `ValidationError` before anything is sent. `confidence` is not the probability of the answer. It says how certain the model is, derived from the whole distribution, and is what you gate an automatic action on. A Noul has no confidence on the wire. Each response also carries `model`, the versioned model that answered (`jev-1.13.0` for `jev-latest`), which you should log, and `usage`. ### A worked example [`JevTicketTriageExample`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/jev/JevTicketTriageExample.scala) asks the three questions above about a support ticket and routes it with thresholds that are ordinary code: escalate when the ticket looks urgent, queue it with its department otherwise, and hand it to a person when Jev is not sure which department. ``` TYPESAFE_API_KEY=... sbt "samples/runMain org.llm4s.samples.jev.JevTicketTriageExample" ``` It is a plain workflow step. Handing a routed ticket to a specialist agent is a `Handoff` at the point where the route is used, and needs an LLM provider as well, so the sample stops at the decision. ## Errors `evaluate` blocks and returns every failure as a `Result`: (not compile-checked) ```scala def describe(error: LLMError): String = error match { case _: ValidationError => "the request is wrong: fix it, retrying will not help" case _: AuthenticationError => "the key was rejected" case rate: RateLimitError => s"rate limited, retry after ${rate.retryAfter.getOrElse(30.seconds)}" case svc: ServiceError => s"TypeSafe failed with ${svc.httpStatus}" case _: TimeoutError => "no answer in time" case _: NetworkError => "could not reach TypeSafe" case _: CancelledError => "the calling thread was interrupted" case other => other.message } ``` | What happened | Error | |---|---| | The request breaks a documented limit | `ValidationError`, before anything is sent | | 401, 403 | `AuthenticationError` | | 400, 422 | `ValidationError`, carrying the server's explanation | | 429 | `RateLimitError`, carrying the delay the server asked for | | 408, 5xx including `529 Overloaded`, and any other status | `ServiceError` | | No connection, or no answer within `timeout` | `NetworkError`, `TimeoutError` | | A 200 whose body is not the documented shape, that leaves a question unanswered or answers one that was not asked, or that answers one with another type, an option not offered or a level not described, or that leaves out an option or level that was asked | `ProcessingError` | | The calling thread is interrupted | `CancelledError`, with the interrupt flag kept | TypeSafe does not document the JSON shape of an error body, so the text in the error is a best effort (a `message`, an `error.message`, ...), truncated, and never the whole body. If a server echoes your API key in an error body, the key is removed before anything is read from it, and an error about a 200 that quotes the key masks it as `***`. The forms recognised are the key as written, JSON-escaped (`\/` and `\uXXXX` included) and URL-encoded as `java.net.URLEncoder` writes it (`tsk/live+0` as `tsk%2Flive%2B0`). Any other transformation of the key, or a part or prefix of it, is not recognised and would not be masked. An error body nested more than 32 levels deep (real ones take a handful of levels, four for a FastAPI-style `detail` list) is not read at all: the error carries only the status. A 200 nested more than 64 levels deep is refused as a `ProcessingError`: the envelope takes four levels, so a structured Score level description may nest up to 60, and since Jev echoes each description back, a request with one nested deeper is refused with a `ValidationError` before it is sent. These limits are well below the 512 levels LLM4S allows elsewhere because the client walks an error body and keeps a description in the response, and at 512 levels those recursions can overflow a small thread stack. ## Retries, and what is not de-duplicated Transient failures are retried the way TypeSafe's own SDKs do (`JevRetryPolicy.default`): a 408, 429 or 5xx, a connection failure or a timeout, up to two retries, waiting 0.5 s doubling to at most 5 s with a quarter of each wait randomly taken off, within a 30 s budget for the whole call. A delay the server asks for (`Retry-After`, or `retry-after-ms`, which wins when both are sent) replaces the computed one, and no retry is started whose wait would reach the budget. A rejected key, an invalid request and an interrupt are never retried, and the last error is returned as it is. **There is no idempotency key.** TypeSafe's documentation describes no idempotency key, and no other mechanism to de-duplicate a request, so this client does not invent one: each attempt of a retried request is a separate, billable call. If TypeSafe documents a header for it, attach it to the request, and it is sent unchanged on every attempt: (not compile-checked) ```scala val request = JevRequest("...", Map("urgent" -> JevQuestion.noul("Is it?"))) .withHeader("X-Correlation-Id", "order-4711") ``` Headers a client sets itself (`Authorization`, `Content-Type`, `Accept`, ...) cannot be replaced, and a header name or value with a line break is refused. Header names are case-insensitive: a request header replaces a configured one of the same name in any case, and a map naming one header twice (`X-Trace` and `x-trace`) is refused. ## Limits and what is not verified - Jev is text only (a string, or JSON of text values), has no streaming, and its context is 64k tokens per request, 32k for the `state` plus the longest question. TypeSafe's rate limits can change without notice. See [Models](https://docs.typesafe.ai/models). - The client is tested against a local fake server built from TypeSafe's published API reference. **It has not been run against the live API.** Three details are not in that documentation and are assumptions: the JSON shape of an error body, whether `Retry-After` is seconds or a date (both are read), and which of `Retry-After` and `retry-after-ms` wins when both are sent (this client prefers `retry-after-ms`). - Each HTTP attempt has a 30 s timeout by default, an LLM4S choice (the API documents none). The shared HTTP client reads a response in full, so the cap on a response (16,777,216 UTF-16 code units, counted once the body is decoded to text) refuses an oversized body before it is parsed, not before it is read. --- Source: https://llm4s.org/guide/comparison # Comparing LLM4S with other JVM libraries This page compares LLM4S with LangChain4j, Spring AI, Koog and Embabel, so that you can decide quickly whether it fits your project. It states facts and trade-offs, not rankings. **Last verified: 2026-10-25 for LLM4S, 2026-10-11 for the other projects.** The statements about LLM4S describe the `main` branch on that date; the latest release is v0.5.1; v0.5.0 was the first to publish the module split described in the [v1 scope](../reference/v1-scope). LLM4S's providers, their streaming and tool-calling support and the versions it is tested with are generated from the code into the [compatibility matrix](../reference/compatibility-matrix). Every statement about another project comes from that project's own documentation, fetched on 2026-10-11 and listed under [Sources](#sources). A cell that says *not verified* means it was not checked, not that the feature is absent. The other projects change quickly: re-check anything that matters to you before relying on it, and expect this page to be refreshed at each LLM4S release. 1. TOC ## At a glance, by audience - **Scala developers.** LLM4S is written for Scala 3 only ([troubleshooting](../reference/troubleshooting)). It reports errors as `Result[A]` values, and its tools, agents, RAG and memory are Scala APIs. The other four projects in the table are Java or Kotlin libraries (see the first row); a Scala program can call them, but their documented APIs are written for those languages. - **Java developers.** LLM4S has a Java facade (`llm4s-java-api`, [guide](java)). `JLlmClient` completes and streams a reply and returns structured output as a Java record; tools are defined from Java with `@Tool` methods or a lambda builder; and `JAgent` runs, continues, streams the events of, resumes and recovers an agent run (added in [#1484](https://github.com/llm4s/llm4s/issues/1484), [#1485](https://github.com/llm4s/llm4s/issues/1485) and [#1486](https://github.com/llm4s/llm4s/issues/1486)). Some Scala types still appear in its signatures, and there is no asynchronous call ([what is not here yet](java#what-is-not-here-yet)). - **Spring shops.** LLM4S has a Spring Boot starter (`llm4s-spring-boot-starter`, [guide](spring-boot)). A flat `llm4s.provider` key covers OpenAI, Anthropic and Ollama, and every other provider on the classpath is configured under `llm4s.providers` ([#1467](https://github.com/llm4s/llm4s/issues/1467)). Spring AI is the Spring project's own library, and LangChain4j publishes Spring Boot starters too. ## Capability table | Capability | LLM4S (`main`) | LangChain4j | Spring AI | Koog | Embabel | |---|---|---|---|---|---| | Language and API style | Scala 3 only; `Result[A]` errors; a Java facade and a Kotlin coroutine API | Java; AI Services are interfaces the library implements (annotated `@AiService` in Spring Boot) [L1][L8] | Java; fluent `ChatClient` [S6] | Kotlin DSL and fluent builder-style Java APIs [K1] | Written in Kotlin, usable from Java [E1] | | Typed structured output | `completeStructured[A]` with an `ObjectSchema[A]`, written by hand or derived from a case class with `derives` (`llm4s-schema-derivation`, Experimental, [guide](derived-schemas), [#1472](https://github.com/llm4s/llm4s/issues/1472)); the schema is sent as a JSON-Schema response format; Anthropic gets a best-effort prompt instruction that is not schema-enforced (its Scaladoc). From Java, `complete(prompt, SomeRecord.class)` | Return a POJO or record from an AI Service method; JSON Schema for the providers it lists, prompting otherwise [L1][L7] | `BeanOutputConverter` derives a JSON Schema from a Java class; `entity()` on `ChatClient` [S4][S6] | not verified | not verified | | Tools | `ToolBuilder` with a hand-written parameter schema, or `Tool.of` over a function whose argument type's schema is derived ([typed tools](derived-schemas#typed-tools), [#1473](https://github.com/llm4s/llm4s/issues/1473)); `@Tool` methods or a lambda builder from Java; built-in calculator, date and time, UUID, JSON, file, HTTP, shell and web-search tools ([guide](builtin-tools)) | AI Services can be configured with tools, methods annotated `@Tool`, that the model can call [L1] | `@Tool` on a method, or `MethodToolCallback` and `FunctionToolCallback`; the input schema is generated from the method's parameters [S7]; tool calls are observed [S5] | not verified | not verified | | Agents | Agent runtime on a typed graph runtime, handoffs ([guide](agents/)) | `langchain4j-agentic`, marked experimental: sequential, loop, parallel and conditional workflows and a supervisor agent [L10]; an example of an agent with memory, tools and RAG [L2] | A guide to agentic patterns (chain, parallelization, routing, orchestrator-workers, evaluator-optimizer) built on `ChatClient` [S10]; the Advisors API intercepts and enhances model interactions [S6] | Basic, graph-based, functional and planner (beta) agents [K1] | Goal Oriented Action Planning by default; Utility AI supported [E1] | | Guardrails | Input and output guardrails, including LLM-as-judge ones, and composition ([guide](agents/guardrails)) | Input and output guardrails on AI Services, marked experimental [L11] | not verified | not verified | not verified | | Memory | `llm4s-memory`: in-memory, SQLite and embedding-backed stores; a Postgres store in the separate `llm4s-memory-postgres` artifact ([guide](agents/memory)) | Chat memory, shared or per user through `ChatMemoryProvider` [L1] | `ChatMemory`, with `MessageWindowChatMemory` as its built-in implementation, backed by `ChatMemoryRepository` implementations, including in-memory, JDBC, Cassandra, Neo4j, MongoDB and Redis [S6] | not verified | not verified | | RAG and stores | Vector stores: SQLite, PostgreSQL (pgvector), Qdrant, Elasticsearch and OpenSearch (`llm4s-vectorstore-elasticsearch`, Experimental), Redis and Valkey (`llm4s-vectorstore-redis`, Experimental); keyword indexes: SQLite FTS5, PostgreSQL full-text; hybrid fusion and Cohere reranking ([guide](vector-store)) | `ContentRetriever` and `RetrievalAugmentor` [L1]; a long table of embedding stores [L4] | A list of vector store implementations [S2] | not verified | not verified | | Model providers | 16 chat and 5 embedding provider ids on `main`, each row generated from the provider modules' descriptors in the [compatibility matrix](../reference/compatibility-matrix), with streaming and tool-calling flags; any OpenAI-compatible endpoint through configuration ([providers](providers)) | A table of supported language models [L3] | A comparison table of supported chat models [S8] | not verified | not verified | | MCP | Client (stdio, SSE and Streamable HTTP transports) and a server class in `llm4s-mcp`; no user guide yet ([#1442](https://github.com/llm4s/llm4s/issues/1442)) | Client with Streamable HTTP, stdio, WebSocket and Docker stdio [L5]; a stdio server in the `langchain4j-community-mcp-server` module [L9] | Client and server; stdio, Streamable HTTP, stateless Streamable HTTP and SSE; Spring Boot starters [S3] | MCP and A2A integrations, both marked beta [K1] | not verified | | Observability | Langfuse, OpenTelemetry and Prometheus modules, a trace collector and cost tracking ([guide](observability/)) | Listeners and events; Micrometer listeners; OpenTelemetry semantic conventions; Arize integrations [L6] | Micrometer metrics and tracing for `ChatClient`, `ChatModel`, `EmbeddingModel`, `ImageModel` and `VectorStore` [S5] | OpenTelemetry is listed among its integrations [K1] | not verified | | Testing and evaluation | RAGAS-style metrics and a benchmark harness for RAG ([guide](rag-evaluation)); LLM-as-judge guardrails; `llm4s-testkit` (scripted clients for application tests, [#1475](https://github.com/llm4s/llm4s/issues/1475)); `llm4s-provider-testkit` for provider authors | not verified | `Evaluator`, `RelevancyEvaluator` and `FactCheckingEvaluator` [S1] | not verified | not verified | | Frameworks and runtimes | Spring Boot 3 and 4 starter, every provider ([guide](spring-boot)), a Quarkus sample with a CDI producer ([guide](quarkus)), cats-effect ([guide](cats-effect)), ZIO ([guide](zio)), Pekko Streams ([guide](pekko)), Project Reactor ([guide](java-reactor)), a Kotlin coroutine API, a Java facade ([guide](java)); the versions each is tested with are in the [compatibility matrix](../reference/compatibility-matrix#tested-versions) | Spring Boot 3 and 4 starters and `@AiService`; example projects for Spring Boot, Quarkus, Helidon, Payara Micro, WildFly and Jakarta EE/MicroProfile [L2][L8] | Spring AI 2.0.x supports Spring Boot 4.0.x and 4.1.x [S9]; Spring Boot starters, for example for its MCP client and server [S3] | Spring Boot and Ktor integrations (both beta) [K1] | Built on Spring [E1] | | Java baseline | JDK 21: CI runs only JDK 21, and the agent runtime depends on Ox, whose jars are JDK 21 bytecode ([#1493](https://github.com/llm4s/llm4s/issues/1493)) | Its Spring Boot integration page states a minimum Java version, quoted in the note below the table [L8] | not verified | not verified | not verified | | Maturity | Pre-1.0: latest release v0.5.1; stability tiers (Frozen, Beta, Experimental) in the [v1 scope](../reference/v1-scope) | Latest release 1.22.0, 2026-10-08 [R1]; some modules, such as agents and guardrails, are marked experimental [L10][L11] | Latest GA release 2.0.1, 2026-08-21 [R2] | Latest release 1.3.0, 2026-09-24 [R3]; its Spring Boot, Ktor and planner agents are marked beta [K1] | Latest release 1.5.3, 2026-10-05 [R4] | Note on the *Java baseline* row: LangChain4j's Spring Boot integration page says "LangChain4j Spring Boot integration requires Java 17" [L8]. This is a statement about that project, quoted as fetched on 2026-10-11. ## What LLM4S has not got yet Plainly, as of the date above: - **A stable schema derivation.** `llm4s-schema-derivation` is Experimental, outside the 1.0 frozen surface. - **An asynchronous Java call.** See the audience note above. - **A smaller integration catalogue.** Vector stores for SQLite, PostgreSQL, Qdrant, Elasticsearch, OpenSearch, Redis and Valkey, against the long lists of LangChain4j and Spring AI. Quarkus has a [guide](quarkus) and a runnable sample with a CDI producer, but no extension. - **An MCP user guide.** The client and server exist; the guide is an open issue ([#1442](https://github.com/llm4s/llm4s/issues/1442)). - **No Scala 2.13 artifact.** The artifacts are Scala 3 only. - **A stable 1.0 API.** The split artifacts are first published in 0.5.0, and the compatibility promise starts at 1.0. ## When not to choose LLM4S - Your application is Java and needs an API with no Scala types in its signatures, or asynchronous calls built in. - You are a Spring team that wants the widest set of ready-made integrations, or you need a vector store or model provider that LLM4S does not have a module for. The two projects whose pages list their stores, LangChain4j and Spring AI, each list far more vector stores than LLM4S has. - You need to stay on a JDK older than 21. - You need a stable, frozen API today. LLM4S is pre-1.0. ## When LLM4S fits These are the things its code does today, not claims about quality: - You write Scala 3 and want errors as `Result[A]` values with typed error classes ([error handling](error-handling)) instead of exceptions. - You want a provider layer that is a small published interface (`ProviderDescriptor`), so that adding a provider is adding a dependency ([writing a provider](writing-a-provider)). - You want guardrails (input and output validators, including LLM-as-judge ones) and agent handoffs in the same library as the client, usable on an agent run ([guardrails](agents/guardrails)). - You use cats-effect or ZIO and want `AgentIO` or `AgentZ` ([cats-effect](cats-effect), [ZIO](zio)). ## Sources All fetched on 2026-10-11, against LangChain4j 1.22.0, Spring AI 2.0.1, Koog 1.3.0 and Embabel 1.5.3. The column shows the fact taken from each page; nothing else on these pages is relied on. | Tag | Page | Fact used | |---|---|---| | L1 | [LangChain4j: AI Services](https://docs.langchain4j.dev/tutorials/ai-services) | Return-type structured output; tools; chat memory (shared or per user); `ContentRetriever` for RAG | | L2 | [langchain4j-examples](https://github.com/langchain4j/langchain4j-examples) | Example projects for Spring Boot, Quarkus, Helidon, Payara Micro, WildFly, Jakarta EE/MicroProfile; an agent example with memory, tools and RAG | | L3 | [LangChain4j: language models](https://docs.langchain4j.dev/integrations/language-models/) | A comparison table of supported language models | | L4 | [LangChain4j: embedding stores](https://docs.langchain4j.dev/integrations/embedding-stores/) | A comparison table of supported embedding stores | | L5 | [LangChain4j: MCP](https://docs.langchain4j.dev/tutorials/mcp) | MCP client; Streamable HTTP, stdio, WebSocket (not standardized) and Docker stdio; stdio server in LangChain4j Community | | L6 | [LangChain4j: observability](https://docs.langchain4j.dev/tutorials/observability) | Listeners and events; Micrometer listeners; OpenTelemetry Generative AI Semantic Conventions; Arize Phoenix and AX | | L7 | [LangChain4j: structured outputs](https://docs.langchain4j.dev/tutorials/structured-outputs) | JSON Schema for Amazon Bedrock, Azure OpenAI, Google AI Gemini, Mistral, Ollama and OpenAI; prompting otherwise; POJOs and records | | L8 | [LangChain4j: Spring Boot integration](https://docs.langchain4j.dev/tutorials/spring-boot-integration) | Starters for Spring Boot 3 (3.5+) and 4 (4.0+); `@AiService`; its stated Java minimum (see the note below the capability table) | | L9 | [LangChain4j: building a Java MCP server](https://docs.langchain4j.dev/tutorials/mcp-stdio-server) | A stdio MCP server in the community module `langchain4j-community-mcp-server` | | L10 | [LangChain4j: agents and agentic AI](https://docs.langchain4j.dev/tutorials/agents) | `langchain4j-agentic`, experimental; sequential, loop, parallel, conditional workflows; supervisor agent | | L11 | [LangChain4j: guardrails](https://docs.langchain4j.dev/tutorials/guardrails) | Input and output guardrails, only on AI Services; experimental | | S1 | [Spring AI: evaluation testing](https://docs.spring.io/spring-ai/reference/api/testing.html) | `Evaluator`, `RelevancyEvaluator`, `FactCheckingEvaluator` | | S2 | [Spring AI: vector databases](https://docs.spring.io/spring-ai/reference/api/vectordbs.html) | A list of vector store implementations | | S3 | [Spring AI: MCP overview](https://docs.spring.io/spring-ai/reference/api/mcp/mcp-overview.html) | Client and server; stdio, Streamable HTTP, stateless Streamable HTTP, SSE; Spring Boot starters | | S4 | [Spring AI: structured output converters](https://docs.spring.io/spring-ai/reference/api/structured-output/converters.html) | `BeanOutputConverter` derives a JSON Schema from a Java class | | S5 | [Spring AI: observability](https://docs.spring.io/spring-ai/reference/observability/index.html) | Micrometer metrics and tracing for `ChatClient`, `ChatModel`, `EmbeddingModel`, `ImageModel`, `VectorStore`; `spring.ai.tool` observations | | S6 | [Spring AI: ChatClient](https://docs.spring.io/spring-ai/reference/api/chatclient.html) | Fluent API; `entity()`; Advisors API; `ChatMemory` with `MessageWindowChatMemory` as the built-in implementation, and its `ChatMemoryRepository` implementations | | S7 | [Spring AI: tool calling](https://docs.spring.io/spring-ai/reference/api/tools.html) | `@Tool`, `MethodToolCallback`, `FunctionToolCallback`; input schema generated from the method's parameters | | S8 | [Spring AI: chat models comparison](https://docs.spring.io/spring-ai/reference/api/chat/comparison.html) | A comparison table of supported chat models | | S9 | [Spring AI: getting started](https://docs.spring.io/spring-ai/reference/getting-started.html) | Spring AI 2.0.x supports Spring Boot 4.0.x and 4.1.x | | S10 | [Spring AI: building effective agents](https://docs.spring.io/spring-ai/reference/api/effective-agents.html) | Chain, parallelization, routing, orchestrator-workers and evaluator-optimizer patterns | | K1 | [Koog documentation](https://docs.koog.ai/) | JetBrains JVM agent framework; Kotlin DSL and fluent builder-style Java APIs; basic, graph, functional and planner (beta) agents; Spring Boot and Ktor (beta); OpenTelemetry; MCP and A2A (beta) | | E1 | [embabel-agent](https://github.com/embabel/embabel-agent) | Agent framework for the JVM; written in Kotlin with a natural Java usage model; GOAP by default and Utility AI; built on Spring | | R1 | [LangChain4j releases](https://github.com/langchain4j/langchain4j/releases) | Latest release 1.22.0, published 2026-10-08 | | R2 | [Spring AI releases](https://github.com/spring-projects/spring-ai/releases) | Latest GA release v2.0.1, published 2026-08-21 | | R3 | [Koog releases](https://github.com/JetBrains/koog/releases) | Latest release 1.3.0, published 2026-09-24 | | R4 | [Embabel releases](https://github.com/embabel/embabel-agent/releases) | Latest release v1.5.3, published 2026-10-05 | If you spot a statement here that is out of date or unfair to another project, please open an issue or a pull request. --- Source: https://llm4s.org/guide/migrating-from-langchain4j # Coming from LangChain4j or Spring AI If you know LangChain4j or Spring AI, this page maps the concepts you already use to LLM4S, with working code for the five things most programs do: call a model, get typed output, give the model a tool, keep memory, and retrieve from documents. For whether LLM4S is the right choice, read the [comparison](comparison) first, including [what it has not got yet](comparison#what-llm4s-has-not-got-yet). Every LLM4S snippet below is compiled by a test in this repository (`ComparisonGuideSpec`), which also checks that the code on this page is, word for word, the code it compiles, so the page cannot drift from the API. All but two are also run, against a scripted client, with no network. The two that are only compiled (the configuration one and the RAG one) say so. The LangChain4j and Spring AI sides show concept names taken from their own documentation, linked at the end, and no invented API. 1. TOC ## The concepts side by side | You use | In LLM4S | Note | |---|---|---| | An AI Service, a Java interface that the library implements [L1] | `LLMClient`, or an `Agent` when tools are involved | There is no declarative interface-to-implementation layer: you call the client, or build an agent. See [the comparison](comparison) | | A chat model | `LLMClient`, from `LLMConnect.getClient(providerConfig)` | The provider is chosen by configuration, in a named section of `application.conf` ([providers](providers)) | | Structured output by return type [L1][L7] | `client.completeStructured[A](conversation, schema)` | The schema is written by hand, not derived from the type | | Tools [L1] | `ToolBuilder` and a `ToolRegistry`, run by an `Agent` | Parameter schemas are written by hand | | Chat memory [L1] | A `Conversation` you pass on each call, and `llm4s-memory` for long-term facts | See the memory section: they are different mechanisms | | `ContentRetriever` and RAG [L1] | `RAG` in `llm4s-rag` | Needs an embedding provider | | Spring AI `ChatClient` [S6] | `LLMClient` | Spring's fluent builder versus a plain call | | Spring AI `entity()` [S6] | `completeStructured` | As above | The largest difference is not a class: **LLM4S returns `Result[A]` instead of throwing**. Every call in this page returns an `Either`-shaped value, and the snippets chain them with a `for` comprehension. See [error handling](error-handling) for the error types. ## Imports used below (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.agent.memory.SimpleMemoryManager import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.{ LLMClient, LLMConnect } import org.llm4s.llmconnect.model._ import org.llm4s.model.ModelRegistryService import org.llm4s.rag.RAG import org.llm4s.rag.RAG.RAGConfigOps import org.llm4s.toolapi.{ ObjectSchema, Schema, ToolBuilder, ToolRegistry } import org.llm4s.types.Result import upickle.default.{ macroRW, ReadWriter } ``` ## 1. Calling a model The client comes from configuration. This snippet is compiled but not run, because it needs a configured provider: (not compile-checked) ```scala def connect(): Result[LLMClient] = for { providerConfig <- Llm4sConfig.defaultProvider() registryService <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registryService client <- LLMConnect.getClient(providerConfig) } yield client ``` With a client, a call is a conversation in and a completion out: (not compile-checked) ```scala val reply: Result[String] = for { conversation <- Conversation.fromPrompts("You answer in one sentence.", "What is Scala?") completion <- client.complete(conversation) } yield completion.content ``` `reply` is a `Right` with the text, or a `Left` with an `LLMError`. Nothing is thrown. The test checks that the system message and the user message are sent, in that order. ## 2. Typed structured output In LangChain4j you change an AI Service method's return type [L1]. In Spring AI you call `entity()`. Spring AI's reference shows: ```java ActorFilms actorFilms = chatClient.prompt() .user("Generate the filmography for a random actor.") .call() .entity(ActorFilms.class); ``` *(From the [Spring AI `ChatClient` page](https://docs.spring.io/spring-ai/reference/api/chatclient.html), Spring AI 2.0.1, fetched 2026-10-11.)* In LLM4S you describe the shape once as a schema and pass it: (not compile-checked) ```scala case class Person(name: String, age: Int) object Person { implicit val rw: ReadWriter[Person] = macroRW } val personSchema: ObjectSchema[Person] = Schema .`object`[Person]("A person") .withRequiredField("name", Schema.string("The person's name")) .withRequiredField("age", Schema.integer("The person's age in years")) ``` (not compile-checked) ```scala val person: Result[Person] = for { conversation <- Conversation.fromPrompts("Extract the person.", "Ada Lovelace is 36.") parsed <- client.completeStructured[Person](conversation, personSchema) } yield parsed ``` What to know: - **The schema is not derived from `Person`.** You write it, and keep it in step with the case class yourself. A mismatch shows up when a reply fails to parse, not at compile time ([#1472](https://github.com/llm4s/llm4s/issues/1472) is the open issue for derivation). - The schema is sent to the provider as a JSON-Schema response format, in strict mode: every property is listed as required, optional ones included. The tests check the properties and the required list that are sent. Providers that enforce a schema natively use it. Anthropic gets a best-effort instruction in the prompt that is not enforced, according to `completeStructured`'s Scaladoc. - A reply that is not the requested shape gives a `Left`, not an exception. The test covers this too. ## 3. A tool the model can call LangChain4j lets an AI Service be configured with tools the model can call [L1]. In LLM4S a tool is a `ToolFunction` built with `ToolBuilder`, and an `Agent` runs the loop in which the model asks for it: (not compile-checked) ```scala case class Forecast(city: String, summary: String) object Forecast { implicit val rw: ReadWriter[Forecast] = macroRW } private val forecastSchema = Schema .`object`[Map[String, Any]]("Forecast parameters") .withProperty(Schema.property("city", Schema.string("The city to look up"))) val forecastTool = ToolBuilder[Map[String, Any], Forecast]( name = "get_forecast", description = "Returns the forecast for a city", schema = forecastSchema ).withHandler(extractor => extractor.getString("city").map(city => Forecast(city, "sunny"))).buildSafe() ``` (not compile-checked) ```scala val result = for { tool <- forecastTool agent <- Agent.builder("forecast-agent", client).withTools(new ToolRegistry(Seq(tool))).build() state <- agent.run("What is the weather in Oslo?") } yield state ``` `result` is a `Result` holding the final agent state, and `state.answer` is the answer text. In the test the scripted client asks for `get_forecast` once and then answers with what the tool returned, which shows that the tool is offered with the request, runs, and has its output fed back. A real model decides for itself whether to call it. Things to know: the handler returns an `Either[String, R]`, so a parameter that is missing or of the wrong type is an error the model can see and retry, not an exception; and there are ready-made tools (calculator, date and time, UUID, JSON, files, HTTP, shell and web search) in the [built-in tools guide](builtin-tools). ## 4. Memory In LangChain4j, an AI Service can be given a chat memory, shared or one per user [L1], and Spring AI has a `ChatMemory` [S6]. LLM4S splits this in two: - **Within a conversation**, history is a `Conversation` value: an immutable list of messages that you pass to each call. - **Across conversations**, `llm4s-memory` stores facts, knowledge, entities and past messages, and returns the relevant ones as text to put in a prompt ([memory guide](agents/memory)): (not compile-checked) ```scala val context: Result[String] = for { m1 <- SimpleMemoryManager.empty.recordUserFact("Prefers Scala over Java", Some("user-1"), Some(0.9)) m2 <- m1.recordKnowledge("Scala 3 has opaque types", "docs") ctx <- m2.getRelevantContext("Scala") } yield ctx ``` The test checks the exact text of `context`: ```text # Retrieved Context ## Relevant Knowledge - Scala 3 has opaque types ## User Preferences - Prefers Scala over Java ``` The in-memory store matches **keywords**: the query `"What does the user like?"` shares no word with the stored text and returns an empty string (the test checks that too). Use the embedding-backed store when you need a match by meaning. ## 5. RAG LangChain4j configures an AI Service with a `ContentRetriever` [L1]. In LLM4S the retrieval pipeline is `RAG` in the `llm4s-rag` module: ingest text or files, then query. This snippet is compiled but not run, because embedding needs a provider: (not compile-checked) ```scala def buildRag(): Result[RAG] = for { registryService <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registryService rag <- RAG.builder().withEmbeddings("ollama").build } yield rag def askTheDocs(rag: RAG): Result[Seq[String]] = for { _ <- rag.ingestText("Opaque types hide their representation outside the defining scope.", "scala-3") results <- rag.query("What are opaque types?") } yield results.map(_.content) ``` The `import org.llm4s.rag.RAG.RAGConfigOps` at the top is needed: `build` is an extension method, and without the import the compiler says `value build is not a member of org.llm4s.rag.RAGConfig`. Vector stores, hybrid search and reranking are in the [vector store guide](vector-store). ## What you will miss, and what you gain You will miss, today: derivation of schemas and tool definitions from types, a complete Java API, a test kit for your own code, and the breadth of integrations the other projects list. See the [full list](comparison#what-llm4s-has-not-got-yet), each item with its issue. You gain, in code that exists today: `Result[A]` errors with typed error classes, a provider layer that is a small interface you can implement ([writing a provider](writing-a-provider)), guardrails and agent handoffs in the same library ([guardrails](agents/guardrails)), and cats-effect and ZIO modules ([cats-effect](cats-effect), [ZIO](zio)). ## Sources All fetched on 2026-10-11, against LangChain4j 1.22.0 and Spring AI 2.0.1. | Tag | Page | Fact used | |---|---|---| | L1 | [LangChain4j: AI Services](https://docs.langchain4j.dev/tutorials/ai-services) | AI Services; return-type structured output; tools; chat memory, shared or per user; `ContentRetriever` for RAG | | L7 | [LangChain4j: structured outputs](https://docs.langchain4j.dev/tutorials/structured-outputs) | POJOs and records as return types | | S6 | [Spring AI: ChatClient](https://docs.spring.io/spring-ai/reference/api/chatclient.html) | The fluent API; the `entity()` example quoted above; `ChatMemory` | --- Source: https://llm4s.org/guide/derived-schemas # Derived Schemas Write the type once and let `llm4s-schema-derivation` build the JSON schema for structured output, and typed tools, from it. > **Experimental.** The module is new and not part of the 1.0 frozen surface. It is published from `0.5.0` on. 1. TOC --- ## Why `completeStructured` takes an `ObjectSchema` that you build by hand, so every field is written twice, once in the case class and once in the schema, and a mismatch only shows up when a model answers. `llm4s-schema-derivation` derives the schema from the case class with Scala 3's `derives`, so the two cannot drift. It layers on the public API: it builds the same `ObjectSchema` and calls the same `completeStructured`. Nothing in `llm4s-core` changes, and the module adds no dependency. ## Add the module ```sbt libraryDependencies += "org.llm4s" %% "llm4s-schema-derivation" % "0.5.1" ``` ## A first call (not compile-checked) ```scala import org.llm4s.schema.* import upickle.default.ReadWriter @description("An invoice extracted from text") case class Invoice( @description("Name of the vendor or supplier") vendor: String, @description("Total invoice amount as a decimal number") amount: Double, @description("ISO 4217 currency code, e.g. USD, EUR, GBP") currency: String ) derives SchemaOf, ReadWriter ``` `derives SchemaOf` gives the type its schema, and `derives ReadWriter` (uPickle) is what reads the model's answer, as with `completeStructured`. With an `LLMClient` and a `Conversation` in scope, one call replaces the hand-built schema: (not compile-checked) ```scala val invoice: Result[Invoice] = client.completeStructuredOf[Invoice](conversation) ``` `Result` is `org.llm4s.types.Result`. `completeStructuredOf` is an extension method on `LLMClient`, in `org.llm4s.schema`. It sets the same response format as `completeStructured`, so the provider enforces the schema where it can (OpenAI, Gemini) and falls back to an instruction in the prompt where it cannot (Anthropic). To see the schema that is sent: (not compile-checked) ```scala val json = SchemaOf[Invoice].toJsonSchema() ``` which is ```json { "type": "object", "description": "An invoice extracted from text", "properties": { "vendor": { "type": "string", "description": "Name of the vendor or supplier" }, "amount": { "type": "number", "description": "Total invoice amount as a decimal number" }, "currency": { "type": "string", "description": "ISO 4217 currency code, e.g. USD, EUR, GBP" } }, "required": ["vendor", "amount", "currency"], "additionalProperties": false } ``` This is exactly the schema that `Schema.object[Invoice](...)` with three `withRequiredField` calls builds by hand; the module's tests compare the two. ## What is derived | Scala type | JSON schema | |---|---| | `String` | `string` | | `Int`, `Long` | `integer` | | `Double`, `Float` | `number` | | `Boolean` | `boolean` | | `BigDecimal`, `BigInt` | `string` (uPickle writes and reads them as JSON strings) | | `Option[A]` | the schema of `A`, also allowing `null`; the property is not `required` unless strict (below) | | `List[A]`, `Vector[A]`, `Seq[A]`, `Set[A]` | `array` of the schema of `A` | | a case class | `object` with one property per field, in declaration order, `additionalProperties: false` | | an `enum` or sealed hierarchy of singletons | `string` limited to the case names (a case's `@upickle.implicits.key`, where it has one) | | an `enum` or sealed trait whose cases have fields | `anyOf` with one schema per case: see [Sum types](#sum-types) | Case classes nest, and they can be generic (`Box[Int]` is described for `Int`). (not compile-checked) ```scala case class Contact( name: String, email: Option[String], tags: List[String] = Nil ) derives SchemaOf, ReadWriter enum Priority derives SchemaOf, ReadWriter { case Low, Medium, High } ``` ## Tool parameters A tool's parameters are a schema too. `SchemaOf[A].definition` is the derived schema as `llm4s-core`'s schema model, which `ToolBuilder` takes as it is, and the handler reads the arguments with the same `ReadWriter`: (not compile-checked) ```scala import org.llm4s.schema.* import org.llm4s.toolapi.{ ToolBuilder, ToolFunction } import org.llm4s.types.Result import upickle.default.{ read, ReadWriter } import scala.util.Try @description("Where to look up the weather") case class WeatherQuery( @description("City name, for example Paris") city: String, @description("Temperature unit; the default is Celsius") unit: Option[TemperatureUnit] ) derives SchemaOf, ReadWriter enum TemperatureUnit derives SchemaOf, ReadWriter { case Celsius, Fahrenheit } val weatherTool: Result[ToolFunction[WeatherQuery, String]] = ToolBuilder[WeatherQuery, String]("get_weather", "Current weather in a city", SchemaOf[WeatherQuery].definition) .withHandler { args => Try(read[WeatherQuery](args.params)).toEither.left.map(_.getMessage).map(query => s"Sunny in ${query.city}") } .buildSafe() ``` The tool is sent in strict mode, so every parameter is required and `unit` is a property that may be `null`. The module's tests check that the parameters the tool sends are the derived schema, and that the handler reads a call. The handler above still reads the arguments itself, and a call it cannot read reaches the model as uPickle's message. [Typed tools](#typed-tools) do the reading for you. ## Typed tools `Tool.of` builds the tool from a function over the arguments' case class: the schema is derived from the type, the call's arguments are decoded into it before your function runs, and the result is written with its uPickle `ReadWriter`. The name of a parameter is written once, in the case class, so the schema, the parsing and the function cannot drift apart. (not compile-checked) ```scala import org.llm4s.schema.* import org.llm4s.toolapi.ToolFunction import org.llm4s.types.Result import upickle.default.ReadWriter @description("A forecast request") case class ForecastQuery( @description("City name, for example Paris") city: String, @description("How many days ahead, from 1 to 7") days: Int ) derives SchemaOf, ReadWriter case class Forecast(city: String, days: Int, summary: String) derives ReadWriter val forecastTool: Result[ToolFunction[ForecastQuery, Forecast]] = Tool.of[ForecastQuery, Forecast]("get_forecast", "Weather forecast for a city") { query => if (query.days < 1 || query.days > 7) Left("days must be between 1 and 7") else Right(Forecast(query.city, query.days, "Sunny")) } ``` The result is the same `ToolFunction` that `ToolBuilder` builds, so it goes into a `ToolRegistry`, an agent or an MCP server like any other tool; nothing in `llm4s-core` changes. The function returns `Either[String, R]`, as a `ToolBuilder` handler does: a `Left` is the error the model sees. `Tool.of` itself returns a `Result`, as `buildSafe()` does, because the arguments must be a JSON object: an enum or another type that is not a case class is refused with a `Left`, as is a schema that cannot be built (see [Recursion](#recursion)). The result type needs a `ReadWriter`, not only a writer, because `ToolFunction` requires one. A call whose arguments do not match the type never reaches your function. Each problem is reported as the `ToolParameterError` that `SafeParameterExtractor` reports for a hand-written tool, and the module's tests compare the two: | The call sends | The error | |---|---| | no `days` | `MissingParameter("days", "integer", )` | | `"days": null` | `NullParameter("days", "integer")` | | `"days": "three"`, `2.5` or a number beyond the `Int` range | `TypeMismatch("days", "integer", )`, as `getInt` refuses them | | arguments that are not an object | `InvalidNesting(, "root", )` for each field | | a key `ForecastQuery` has no field for | nothing: it is ignored, as a hand-written tool ignores it | The problems are collected, every field at once, in declaration order: one is reported as its own message, as a hand-written handler's `Left` would be, several as `ToolParameterError.MultipleErrors`'s. A nested field is named by its path (`address.city`, `lines[0].quantity`), as a dotted `getString("address.city")` names it; a nested object or an array that is not one is a `TypeMismatch` naming the object, as `getObject` reports it. A value outside an enum's cases is a `TypeMismatch` that lists the cases, and a value of the right JSON type that the reader still refuses (`"abc"` for a `BigDecimal`) is a `TypeMismatch` with the reader's reason. An `Option` field may be absent or `null`, and is `None`; every other field is required, including one with a default value, as the schema says. The built-in calculator, re-expressed this way next to the hand-built original, is the `TypedCalculatorExample` sample (`sbt "samples/runMain org.llm4s.samples.toolapi.TypedCalculatorExample"`). ## An agent's answer An agent's response format is a schema too: pass `SchemaOf[A].definition` to `AgentBuilder.withOutput`, and read the answer with `decodeOutput[A]` (see [the agent guide](agents/#a-typed-answer)). `withOutput` is in `llm4s-agent`, which this module does not depend on. (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.schema.* def extractWithAgent(client: LLMClient, email: String): Result[Invoice] = for { agent <- Agent.builder("extractor", client).withOutput(SchemaOf[Invoice].definition).build() result <- agent.run(s"Extract the invoice from this email: $email") invoice <- result.decodeOutput[Invoice] } yield invoice ``` As with `completeStructuredOf`, `A` must be a case class: `build` refuses any other type with a `Left`. ## Sum types An enum or sealed trait whose cases have fields is a sum type: the reply is one of its cases. It derives like any other type. (not compile-checked) ```scala @description("What the support bot decides to do") enum Action derives SchemaOf, ReadWriter { @description("Refund the customer") case Refund(amount: BigDecimal, reason: String) @description("Hand over to a human team") case Escalate(team: Team) case Reply(text: String) } enum Team derives SchemaOf, ReadWriter { case Billing, Shipping } def decide(client: LLMClient, conversation: Conversation): String = client.completeStructuredOf[Action](conversation) match { case Right(Action.Refund(amount, _)) => s"refund $amount" case Right(Action.Escalate(team)) => s"escalate to $team" case Right(Action.Reply(text)) => text case Left(error) => error.message } ``` The schema is an `anyOf` with one alternative per case, written as uPickle writes that case: - **A case with fields** is an object whose first property is the discriminator, a required string that can only be the case's tag (a single-value `enum`), followed by the case's fields. uPickle's discriminator is `"$type"`, or the key set with `@upickle.implicits.key("kind")` on the sealed trait or enum. The tag is the case's name, or its own `@upickle.implicits.key`. A case class without fields (`case class Blank()`) is an object holding only the discriminator. - **A case without fields** (an enum case or a case object) is a string that can only be its tag, since uPickle writes it as a bare string. - A case's `@description` describes its alternative. Sum types nest: a case class field, a `List` or an `Option` of a sum type is described in place (an `Option` adds `{"type": "null"}` to the `anyOf`), and a case may hold a case class or another sum type. OpenAI's strict mode refuses an `anyOf` at the root of a schema, so `completeStructuredOf` asks for a sum type wrapped in an object, `{"value": }`, sends that schema as a `ResponseFormat.JsonSchema`, and reads the reply's `value` into the type: the caller never sees the wrapper. A case class that holds a sum type is an object already and is sent as it is. A case class without one goes through `completeStructured` as before. The wrapped schema is within the subset OpenAI's strict mode accepts (`anyOf` below the root, `additionalProperties: false` and every property required on each object), and Gemini accepts it too; the module's tests check both the subset and the round trip with uPickle for every case. A sum type has no schema in `llm4s-core`'s model, which has no union node: `SchemaOf[Action].toJsonSchema()` is its schema, while `SchemaOf[Action].definition` throws an `UnsupportedOperationException`. So a tool's parameters cannot hold a sum type yet, and `Tool.of` returns a `Left` for one. ## Descriptions The model sees a description for every property and for the object. `@description("...")` sets it: on a case class parameter it describes the field, on a case class, enum or sealed trait it describes the type. The text must be a string literal, because the annotation is read at compile time. Without it a field is described by its type's `@description` if it has one, and otherwise by its name, and a type by its name. ## How it agrees with uPickle The schema describes what uPickle's derived `ReadWriter` reads and writes, so a reply that matches the schema reads back into your type. The module's tests check this in both directions for every supported shape: what uPickle writes satisfies the schema, and a document that satisfies the schema is read into the right value. The points where it matters: - **Field names.** A property is named as uPickle writes the field, so `@upickle.implicits.key("user_name")` is honoured. - **`Option`.** uPickle writes `None` as `null` and `Some(x)` as `x`, and the schema allows exactly that. - **`BigDecimal`.** uPickle writes it as a string and cannot read a JSON number, so the schema says `string`. - **Sum types.** uPickle writes a case with fields as an object with its tag under `"$type"` (or the trait's `@upickle.implicits.key`), and a case without fields as its tag alone; the `anyOf` describes exactly that (see [Sum types](#sum-types)). - **Enums.** uPickle writes a case as its name, or as its `@upickle.implicits.key("...")` when the case (an enum case or a case object) has one, and the `enum` list in the schema holds exactly those strings. - **`Long`.** uPickle writes a `Long` beyond 2^53 (9007199254740992) in either direction as a JSON *string*, because a JSON number that large loses precision in many readers; it reads both forms back. The schema says `integer`, so a value written that large does not match it. A model answering the schema writes a number, which reads back; if your values can be that large and you validate what uPickle writes, describe the field as a string yourself. - **`BigDecimal` and `BigInt` text.** The schema says `string` and cannot say that only numeric text reads back (llm4s-core's schema model has no `pattern`), so give such a field a `@description` that asks for a number. - **Defaults.** uPickle leaves out a field that equals its default when it writes, and reads a missing field as the default. The schema still lists the field: `completeStructured` sends the strict schema, in which every property is required (a model must answer every field), so a field with a default is asked for like any other. ## Strict mode `completeStructured` asks for strict mode, in which every property is `required` and an `Option` is a property that may be `null` instead of being absent. That is what providers with native structured output expect. `SchemaOf[A].toJsonSchema(strict = false)` returns the relaxed form, in which an `Option` property is not listed in `required`. ## Types the module does not know Another type needs a `given SchemaOf` of its own, written next to the `ReadWriter` that reads it. The derivation takes a hand-written given as it is and does not look inside the type, so this also works for a type it would otherwise refuse, such as a recursive type, as long as the given describes what its `ReadWriter` writes and does not itself derive a type that leads back to the one being derived (see [Recursion](#recursion)). `SchemaOf.string` describes a type that is written as a JSON string, and `SchemaOf.stringEnum` one that is written as one of a fixed set of strings. The text you pass is what the model sees for a field of that type, unless the field has its own `@description`. (not compile-checked) ```scala import java.time.Instant given ReadWriter[Instant] = upickle.default.readwriter[String].bimap[Instant](_.toString, Instant.parse) given SchemaOf[Instant] = SchemaOf.string("ISO-8601 instant, for example 2026-10-08T09:30:00Z") case class Meeting(title: String, starts: Instant) derives SchemaOf, ReadWriter ``` ## What cannot be derived These are refused at compile time, with a message that names the type: - **`Map`.** `llm4s-core`'s `ObjectSchema` has a fixed set of properties and no way to describe the values of a map. - **Recursive types** (a `Tree` with `children: List[Tree]`, or an `Expr` sum type with an `Add(left: Expr, right: Expr)` case). They need a JSON Schema `$ref`, which the derivation does not emit yet. One case is caught only at run time: see [Recursion](#recursion). - **A sealed hierarchy nested in another** (a sealed trait whose case is itself a sealed trait), and **a sum type with a generic case** (`case class Holds[A](a: A)`). List the cases directly under one sealed trait or enum, without type parameters. - **`Option` directly inside `Option`** (`Option[Option[Int]]`). uPickle writes it as a JSON array (`[]`, `[null]`, `[1]`), not as the value or `null`. An `Option` inside a `List` inside an `Option` is fine. - **Tuples** (`(Int, String)`). uPickle writes them as JSON arrays of mixed types. Use a case class with named fields. - **Named tuples** (`(a: Int, b: String)`). uPickle has no `ReadWriter` for them, so nothing could read the reply back. Use a case class. - **A class that is not a case class**, and a field of a type nothing describes (a function, for instance). `completeStructuredOf` also needs the type to be a case class or a sum type whose cases have fields, since the model answers with a JSON object: for an enum of singletons or another type it returns a `Left` before calling the model. ### Recursion A recursive type is refused at compile time when the recursion runs through derived instances: a type that refers to itself, directly or through an `Option`, a collection or a generic case class; types that refer to each other and all `derives SchemaOf`; and a type whose own given is written as `SchemaOf.derived` of itself. The compile-time check does not look inside any other hand-written given, so it cannot see a recursion that passes through one which itself calls `SchemaOf.derived`: (not compile-checked) ```scala case class P(q: Option[Q]) case class Q(p: List[P]) object Schemas { given ps: SchemaOf[P] = SchemaOf.derived[P] // compiles: Q's given is hand-written given qs: SchemaOf[Q] = SchemaOf.derived[Q] // compiles: P's given is hand-written } ``` This compiles, and making the instances still works: a derived instance does not look up its fields' instances until its schema is built, so two such givens never wait on each other's initialisation. Building the schema (`SchemaOf[P].toJsonSchema()` or `.definition`) throws an `IllegalStateException` on first use, and on every use after it, naming the type: `recursive type P: llm4s-schema-derivation cannot describe recursive types; core's schema model has no $ref. ...`. `completeStructuredOf[P]` returns the same message as a `Left(ValidationError)` without calling the model. It never hangs and never overflows the stack. The same shape is fine when the given that closes the loop describes the type by hand rather than deriving it, for example `given SchemaOf[Q] = SchemaOf.string("...")`; that is why the check cannot refuse every hand-written given that reaches back. A generic case class may be nested in itself at most 16 deep. `W[W[...[Int]]]` with 16 `W`s is described; with 17 it is taken for a recursion. Spelled out in a field's type, the deeper nesting is refused at compile time; summoned through `W`'s own `derives SchemaOf` instance (`SchemaOf[W[...[Int]]]`), it is refused at run time, when the schema is built, with the same `IllegalStateException` (`recursive type W: ...`). A derived schema has no length or range constraints. When you need `withRange` or `withLengthConstraints`, build that schema by hand as before; `completeStructured` takes both. ## Compile time The derivation is Scala 3 `inline` code plus a small macro that reads annotations and rejects the shapes above. The macro walks each type it reaches once, so a type reached along many paths does not multiply the work. For a case class with 30 fields, one measurement of an incremental compile of the single file took about 8 seconds with `derives SchemaOf, ReadWriter` against about 5 seconds with `derives ReadWriter` alone, so roughly 3 seconds more, on a busy machine. Measure your own project if compile time matters to you. --- Source: https://llm4s.org/guide/testing-with-the-testkit # Testing with the Testkit `llm4s-testkit` is a scriptable fake `LLMClient` for the tests of **your** code. It answers from a script you write, records what your code sent, and never touches the network, so a test that calls an LLM is fast, free and the same on every run. It is for people who *use* llm4s. If you are writing a provider module, see [Writing a provider](writing-a-provider) instead (`llm4s-provider-testkit`). ```sbt // same version as llm4s-core; test scope only libraryDependencies += "org.llm4s" %% "llm4s-testkit" % llm4sVersion % Test ``` The module depends on `llm4s-core` only and has no test framework in its API, so it works with whichever one you use. The examples below use ScalaTest; [Other test frameworks](#other-test-frameworks) shows the same test in MUnit, munit-cats-effect, weaver and ZIO Test. ## A first test A `Reply` is what one call returns. `ScriptedLLMClient.sequence` answers the first call with the first reply, the second call with the second, and so on. Every example on this page needs these imports; each section adds the ones its own code needs. (not compile-checked) ```scala import org.llm4s.llmconnect.model._ import org.llm4s.testkit.{ Reply, ScriptedLLMClient, UnscriptedCallError } ``` (not compile-checked) ```scala val client = ScriptedLLMClient.sequence(Reply.text("Paris")) val result = client.complete(Conversation(Seq(UserMessage("Capital of France?"))), CompletionOptions()) result.map(_.content) shouldBe Right("Paris") client.callCount shouldBe 1 ``` ## Check what your code sent Every request is recorded, in order, whether it came through `complete` or `streamComplete`: (not compile-checked) ```scala client.calls.head.lastUserText shouldBe Some("Capital of France?") ``` A `RecordedCall` also holds the whole `conversation`, the `options` and whether it was `streamed`, so a test can check the system prompt, the temperature or the tools your code offered. ## An agent with a tool, end to end Here is a small feature built on an `Agent` with one tool of its own: (not compile-checked) ```scala import org.llm4s.agent.Agent import org.llm4s.agent.graph.GraphError import org.llm4s.error.{ LLMError, ProcessingError, RateLimitError } import org.llm4s.llmconnect.LLMClient import org.llm4s.toolapi._ import org.llm4s.types.Result import upickle.default._ ``` (not compile-checked) ```scala final case class Temperature(city: String, celsius: Double) derives ReadWriter val temperatureTool: Result[ToolFunction[Map[String, Any], Temperature]] = ToolBuilder[Map[String, Any], Temperature]( "get_temperature", "Current temperature in a city, in Celsius.", Schema .`object`[Map[String, Any]]("Parameters") .withProperty(Schema.property("city", Schema.string("City name"))) ).withHandler(params => params.getString("city").map(city => Temperature(city, 21.0))).buildSafe() def packingAdvice(client: LLMClient, city: String): Result[String] = for { tool <- temperatureTool agent <- Agent.builder("packing-assistant", client).withTools(new ToolRegistry(Seq(tool))).build() run <- agent.run(s"What should I pack for $city today?") answer <- run.answer.toRight(ProcessingError("packing-advice", s"the run did not complete: ${run.status}")) } yield answer ``` The model's side of the conversation is the script: first it asks for the tool, then it answers with what the tool returned. (not compile-checked) ```scala val client = ScriptedLLMClient.sequence( Reply.toolCall("get_temperature", """{"city": "Oslo"}"""), Reply.text("It is 21 degrees in Oslo: pack light layers.") ) packingAdvice(client, "Oslo") shouldBe Right("It is 21 degrees in Oslo: pack light layers.") ``` The tool's arguments are a JSON object written as a string, so the test needs no JSON library. Text that is not a JSON object throws an `IllegalArgumentException` when the script is built, naming the tool, rather than failing later inside the code under test. Without an `id`, the client numbers the call (`call-1-1`: call 1, first tool call); `id = Some("...")` sets one. For an answer that calls several tools, chain `.withToolCall(name, argumentsJson)`; `Reply.toolCalls(...)` takes core's `ToolCall`s as they are. The recorded calls show what the agent really did: (not compile-checked) ```scala client.callCount shouldBe 2 client.calls.head.options.tools.map(_.name) shouldBe Seq("get_temperature") client.calls(1).messages.last.content should include("21") ``` The first request offered the tool, and the second carried the tool's result back to the model. ## Make the provider fail `Reply.failure` takes any `LLMError`, so a test can see how your code reacts to a rate limit, a timeout or a bad key, with the real error types: (not compile-checked) ```scala val failing = ScriptedLLMClient.sequence(Reply.failure(RateLimitError("openai"))) val outcome = packingAdvice(failing, "Oslo") outcome.left.map(providerError) shouldBe Left(RateLimitError("openai")) ``` An agent wraps a provider error in a `GraphError.NodeFailed` that names the failing node; the error you scripted is its `cause`. `providerError` is the small helper that takes it out: (not compile-checked) ```scala def providerError(error: LLMError): LLMError = error match { case GraphError.NodeFailed(_, _, cause) => cause case other => other } ``` To test retries, put the scripted client under a `ReliableClient`. Its `sleep` is a parameter, so the test does not wait: (not compile-checked) ```scala import org.llm4s.reliability.{ ReliabilityConfig, ReliableClient } ``` (not compile-checked) ```scala val flaky = ScriptedLLMClient.sequence(Reply.failure(RateLimitError("openai")), Reply.text("recovered")) val reliable = new ReliableClient(flaky, "openai", ReliabilityConfig.default, sleep = _ => ()) reliable.complete(conversation, options).map(_.content) shouldBe Right("recovered") flaky.callCount shouldBe 2 ``` (`conversation` and `options` are any `Conversation` and `CompletionOptions`.) ## When the code makes a call you did not script A call past the end of the script does not hang or throw. It returns a `Left(UnscriptedCallError)`: an error type of its own, so a test can tell "my code made a call I did not script" apart from any error a provider (or a `Reply.failure`) returns. It carries the `callNumber`, the `reason` and the `lastMessage`, and its message says which call it was and what was asked, so a test that makes one call too many fails with a message that explains why: (not compile-checked) ```scala val error = ScriptedLLMClient.sequence().complete(conversation, options).left.value error shouldBe an[UnscriptedCallError] error.message should include("no reply is scripted for call 1") error.message should include("Capital of France?") ``` Inside an agent, that error arrives wrapped in the same `NodeFailed` as any provider error. It is a `NonRecoverableError`, so a `ReliableClient` does not retry it. ## Check that every reply was used The opposite mistake is quieter: the code makes fewer calls than you scripted, usually because it took a wrong branch, and the test still passes. `assertExhausted()` returns `Right(())` when every reply of a `sequence` script was used, and otherwise a `Left` whose message names each unused reply and its position: (not compile-checked) ```scala val client = ScriptedLLMClient.sequence(Reply.text("Paris"), Reply.text("Rome")) client.complete(conversation, options) client.remaining shouldBe 1 client.assertExhausted().left.value should include("""reply 2: text "Rome"""") ``` The message reads: ```text 1 of 2 scripted replies was not used (the code under test made 1 call): reply 2: text "Rome" ``` End a test with `client.assertExhausted() shouldBe Right(())`, or let a fixture do it for every test, as the next sections show. `remaining` and `unusedReplies` give the same information as values. A `respondingTo` or `always` client has no fixed script, so for it nothing is ever unused. ## From llm4s-effect and llm4s-zio The scripted client is an ordinary `LLMClient`, so it drives an agent run through `AgentIO` (cats-effect, `llm4s-effect`) or `AgentZ` (ZIO, `llm4s-zio`) exactly as through `Agent`. Both examples use the same agent as above, built by a small helper: (not compile-checked) ```scala def packingAgent(client: LLMClient): Result[Agent] = temperatureTool.flatMap(tool => Agent.builder("packing-assistant", client).withTools(new ToolRegistry(Seq(tool))).build() ) ``` With cats-effect: (not compile-checked) ```scala import cats.effect.IO import cats.effect.unsafe.implicits.global import org.llm4s.effect.cats.AgentIO ``` (not compile-checked) ```scala val client = ScriptedLLMClient.sequence( Reply.toolCall("get_temperature", """{"city": "Oslo"}"""), Reply.text("It is 21 degrees in Oslo: pack light layers.") ) val run = AgentIO[IO](packingAgent(client).value).run("What should I pack for Oslo today?").unsafeRunSync() run.answer shouldBe Some("It is 21 degrees in Oslo: pack light layers.") client.callCount shouldBe 2 client.calls.head.options.tools.map(_.name) shouldBe Seq("get_temperature") client.calls(1).messages.last.content should include("21") ``` With ZIO, in a `ZIOSpecDefault` test, with the same `client`: (not compile-checked) ```scala import org.llm4s.zio.AgentZ import zio.ZIO import zio.test.* ``` (not compile-checked) ```scala for { agent <- ZIO.fromEither(packingAgent(client)) run <- AgentZ(agent).run("What should I pack for Oslo today?") } yield assertTrue( run.answer.contains("It is 21 degrees in Oslo: pack light layers."), client.callCount == 2, client.calls.head.options.tools.map(_.name) == Seq("get_temperature"), client.calls(1).messages.last.content.contains("21") ) ``` A call the script has no reply for fails the effect like any provider error: `AgentIO` raises an `LLMException` whose `error` is the `NodeFailed` around the `UnscriptedCallError`, and `AgentZ` fails with that `NodeFailed`. These examples are the `ScriptedAgentIOSpec` and `ScriptedAgentZSpec` tests of `llm4s-effect` and `llm4s-zio`, which depend on the testkit `% Test` as your build would. ## Other test frameworks The testkit has no test framework in its API, so each framework needs a few lines of glue: a fixture that gives each test a fresh scripted client and fails the test when a scripted reply was not used. Each example below tests the same packing assistant as above, and is a suite in `modules/samples` that CI runs. They reuse `temperatureTool` and `packingAdvice` from [An agent with a tool, end to end](#an-agent-with-a-tool-end-to-end). ### MUnit ```sbt libraryDependencies += "org.scalameta" %% "munit" % "1.0.0" % Test ``` A `FunFixture` whose teardown fails the test: (not compile-checked) ```scala def scripted(replies: Reply*): FunFixture[ScriptedLLMClient] = FunFixture( setup = _ => ScriptedLLMClient.sequence(replies*), teardown = client => client.assertExhausted().left.foreach(unused => fail(unused)) ) ``` (not compile-checked) ```scala scripted( Reply.toolCall("get_temperature", """{"city": "Oslo"}"""), Reply.text("It is 21 degrees in Oslo: pack light layers.") ).test("the packing assistant calls the tool, then answers") { client => assertEquals(packingAdvice(client, "Oslo"), Right("It is 21 degrees in Oslo: pack light layers.")) assertEquals(client.calls.head.options.tools.map(_.name), Seq("get_temperature")) } ``` ### munit-cats-effect ```sbt libraryDependencies += "org.typelevel" %% "munit-cats-effect" % "2.0.0" % Test ``` With cats-effect, the fixture is a `Resource` whose release fails when a reply was not used. The same resource serves munit-cats-effect and weaver: (not compile-checked) ```scala def scripted(replies: Reply*): Resource[IO, ScriptedLLMClient] = Resource.make(IO(ScriptedLLMClient.sequence(replies*)))(client => // `defer`: check when the release runs, after the test, not when the release is built IO.defer(IO.fromEither(client.assertExhausted().left.map(unused => new AssertionError(unused)))) ) ``` `IO.defer` matters: `ResourceFunFixture` builds the release as soon as the resource is acquired, and without it the check would run then, before the test made any call. The test builds its agent through `LLMClientIO`: (not compile-checked) ```scala import cats.effect.{ IO, Resource } import munit.CatsEffectSuite import org.llm4s.effect.cats.{ LLMClientIO, LLMException } ``` (not compile-checked) ```scala val packing = ResourceFunFixture( scripted( Reply.toolCall("get_temperature", """{"city": "Oslo"}"""), Reply.text("It is 21 degrees in Oslo: pack light layers.") ) ) packing.test("the packing assistant calls the tool, then answers") { client => for { tool <- IO.fromEither(temperatureTool.left.map(new LLMException(_))) agent <- LLMClientIO[IO](client).agent("packing-assistant")(_.withTools(new ToolRegistry(Seq(tool)))) run <- agent.run("What should I pack for Oslo today?") } yield { assertEquals(run.answer, Some("It is 21 degrees in Oslo: pack light layers.")) assertEquals(client.calls.head.options.tools.map(_.name), Seq("get_temperature")) } } ``` ### weaver ```sbt libraryDependencies += "com.disneystreaming" %% "weaver-cats" % "0.8.4" % Test testFrameworks += new TestFramework("weaver.framework.CatsEffect") ``` From 0.9.0 weaver is published as `"org.typelevel" %% "weaver-cats"` and needs cats-effect 3.6; the code is the same. A script belongs to one test, so each test `use`s its own `scripted` resource (the one above) rather than a suite-wide `SharedResource`: (not compile-checked) ```scala test("the packing assistant calls the tool, then answers") { scripted( Reply.toolCall("get_temperature", """{"city": "Oslo"}"""), Reply.text("It is 21 degrees in Oslo: pack light layers.") ).use { client => for { tool <- IO.fromEither(temperatureTool.left.map(new LLMException(_))) agent <- LLMClientIO[IO](client).agent("packing-assistant")(_.withTools(new ToolRegistry(Seq(tool)))) run <- agent.run("What should I pack for Oslo today?") } yield expect.all( run.answer.contains("It is 21 degrees in Oslo: pack light layers."), client.calls.head.options.tools.map(_.name) == Seq("get_temperature") ) } } ``` ### ZIO Test ```sbt libraryDependencies ++= Seq( "dev.zio" %% "zio-test" % zioVersion % Test, "dev.zio" %% "zio-test-sbt" % zioVersion % Test ) ``` A `ZLayer` provides the scripted client and the `LLMClientZ` over it. Its finalizer fails the test when the test succeeded but left a reply unused; a test that already failed keeps its own error: (not compile-checked) ```scala import org.llm4s.zio.LLMClientZ import zio.{ Exit, ZIO, ZLayer } import zio.test.* ``` (not compile-checked) ```scala def scripted(replies: Reply*): ZLayer[Any, Nothing, ScriptedLLMClient & LLMClientZ] = ZLayer.scoped( ZIO.acquireReleaseExit(ZIO.succeed(ScriptedLLMClient.sequence(replies*))) { case (client, Exit.Success(_)) => ZIO.fromEither(client.assertExhausted()).orDieWith(unused => new AssertionError(unused)) case _ => ZIO.unit // the test already failed: keep its own error } ) >+> ZLayer.fromFunction((client: ScriptedLLMClient) => LLMClientZ(client)) ``` (not compile-checked) ```scala test("the packing assistant calls the tool, then answers") { for { client <- ZIO.service[ScriptedLLMClient] tool <- ZIO.fromEither(temperatureTool) agent <- ZIO.serviceWithZIO[LLMClientZ](_.agent("packing-assistant")(_.withTools(new ToolRegistry(Seq(tool))))) run <- agent.run("What should I pack for Oslo today?") } yield assertTrue( run.answer.contains("It is 21 degrees in Oslo: pack light layers."), client.calls.head.options.tools.map(_.name) == Seq("get_temperature") ) }.provide( scripted( Reply.toolCall("get_temperature", """{"city": "Oslo"}"""), Reply.text("It is 21 degrees in Oslo: pack light layers.") ) ) ``` Each suite also has a test that proves its fixture fails a test that leaves a reply unused: `TestkitMUnitSuite`, `TestkitMUnitCatsEffectSuite`, `TestkitWeaverSuite` and `TestkitZioSpec`, in `modules/samples/src/test/scala/org/llm4s/samples/guides`. ## Choose the reply from the conversation When the answer should not depend on how many calls came before, use `respondingTo`. The first case that matches the conversation decides the reply; a conversation no case matches fails like an unscripted call: (not compile-checked) ```scala val client = ScriptedLLMClient.respondingTo { case c if c.messages.lastOption.exists(_.content.contains("Oslo")) => Reply.text("cold") case c if c.messages.lastOption.exists(_.content.contains("Cairo")) => Reply.text("hot") } client.complete(Conversation(Seq(UserMessage("Weather in Cairo?"))), options).map(_.content) shouldBe Right("hot") client.complete(Conversation(Seq(UserMessage("Weather in Oslo?"))), options).map(_.content) shouldBe Right("cold") ``` `ScriptedLLMClient.always(reply)` gives every call the same reply. ## Streaming `streamComplete` replays the reply as chunks: the text in pieces of at most `chunkSize` characters (16 unless you change it), each tool call, then a last chunk that carries the finish reason (`stop`, or `tool_calls` when the reply calls tools). (not compile-checked) ```scala import scala.collection.mutable.ArrayBuffer ``` (not compile-checked) ```scala val client = ScriptedLLMClient.sequence(Reply.text("Hello, world")).withChunkSize(5) val chunks = ArrayBuffer.empty[StreamedChunk] client.streamComplete(conversation, options, chunks += _) chunks.flatMap(_.content).mkString shouldBe "Hello, world" chunks.last.finishReason shouldBe Some("stop") ``` ## Usage, model and context window A reply can report token usage and a model name (`Reply.text("hi").withModel("gpt-test").withUsage(...)`), which is what code that tracks cost reads. The client reports a 128,000-token context window and reserves 4,096 tokens for the answer; `withContextWindow` and `withReserveCompletion` change that, which is how you test code that trims a conversation to fit. ## Good to know - The client is thread-safe: calls from several threads are recorded one at a time and each gets its own position in the script. - `withContextWindow`, `withReserveCompletion` and `withChunkSize` return a **fresh** client, with the same script and no recorded calls. - The kit registers no provider, so it never changes which provider `ProviderRegistry` or `Llm4sConfig` selects. You pass the client to the code under test yourself. - It does not contain a fake embedding client or a fake tracer, and it does not record and replay real provider traffic. - Its API is Scala. A Java-friendly fake is tracked in [#1497](https://github.com/llm4s/llm4s/issues/1497). - The hand-written mocks in the [Testing Guide](../getting-started/testing-guide) (`MockLLMClient`, `FailingMockClient` and the rest) do the same job with more code, and are still fine for a one-off. ## See also - [Error Handling](error-handling) for the error types a `Reply.failure` can carry. - [Reliability Guide](https://github.com/llm4s/llm4s/blob/main/docs/reliability-guide.md) for `ReliableClient`. --- Source: https://llm4s.org/guide/multimodal # Multimodal content: images and documents Send images and documents to a model in a user message or a tool result, and know before the request goes out whether the provider can take them. --- ## Content parts A `UserMessage` and a `ToolMessage` carry `parts: Vector[ContentPart]`. A part is one of: - `ContentPart.Text(text)`; - an image, built with `ContentPart.image(source, mediaType)`, `mediaType` an `ImageMediaType` (`Png`, `Jpeg`, `Gif`, `WebP`, `Bmp`, `Tiff`); - a document, built with `ContentPart.document(source, mediaType)`, `mediaType` a `DocumentMediaType` (`Pdf`, `PlainText`, `Markdown`, `Html`, `Csv`). The source, an `org.llm4s.media.MediaSource`, says where the bytes are: | Source | Meaning | |---|---| | `MediaSource.Url(uri)` | The provider fetches it. Must be absolute; a `data:` URL is refused, use `Inline`. | | `MediaSource.Inline(bytes)` | The bytes themselves, at most `ContentPart.MaxInlineBytes` (5 MiB). | | `MediaSource.ProviderFile(provider, id)` | A file already uploaded to `provider` (`openai`, `gemini`), by its id. | (not compile-checked) ```scala import org.llm4s.llmconnect.model.* import org.llm4s.media.{ MediaSource, MediaType } import java.net.URI import java.nio.file.{ Files, Path } for { chart <- ContentPart.image(MediaSource.Url(URI.create("https://example.com/q3.png")), MediaType.Png) report <- ContentPart.document(MediaSource.Inline(IArray.from(Files.readAllBytes(Path.of("q3.pdf")))), MediaType.Pdf) answer <- client.complete(Conversation(Seq(UserMessage(Vector(ContentPart.Text("Summarise these."), chart, report))))) } yield answer.content ``` `UserMessage(text)` and `ToolMessage(text, toolCallId)` build the one-text-part message, as before. `content` is a message's text view: its text parts joined by newlines, `""` for a message that is all media. ## Why inline media is bounded `ContentPart.image` and `ContentPart.document` return `Left(ValidationError)` for inline bytes over 5 MiB, and a part read from JSON is checked the same way. An agent thread's every checkpoint is a full snapshot of its state, so each retained checkpoint keeps a copy of every inline part - base64-encoded, a third larger - for as long as the message stays in the thread. Put larger media at a URL, or upload it to the provider and send a `ProviderFile`; both are held by reference. ## What each provider sends A client maps every part its API takes and fails the request, before sending anything, with an `UnsupportedContentPartError` naming the provider, the part (`ContentPart.kind`, e.g. `image (url, image/png)`) and the role. No part is dropped and none is turned into text. A `ProviderFile` goes only to the provider it names. | Provider | User message | Tool result | |---|---|---| | OpenAI, Azure OpenAI, Requesty | text; image `Url`, `Inline`; PDF `Inline`, own `ProviderFile` | text | | OpenAI-compatible (generic, Z.ai, OpenRouter, Mistral, Cohere) | text; image `Url`, `Inline` | text | | OpenRouter, in addition | PDF `Url`, `Inline` | | | Mistral, in addition | PDF `Url` | | | DeepSeek | text | text | | Anthropic | text; image (PNG, JPEG, GIF, WebP) `Url`, `Inline`; PDF `Url`, `Inline`; UTF-8 plain text `Inline` | the same | | Gemini API | text; image and document `Inline`, own `ProviderFile` (its Files API `uri`, or its name `files/abc`) | text | | Vertex AI | text; image and document `Inline` or a `gs://` `Url` | text | | Ollama | text; image `Inline` | text | | Bedrock | text; image (PNG, JPEG, GIF, WebP) and document (all five) `Inline` or an `s3://` `Url` | the same | | watsonx.ai | text; image `Inline` | text | A `Url` sent to OpenAI, an OpenAI-compatible provider or Anthropic must be `http` or `https`; the Gemini API fetches no web URL, so upload the file or send it inline. "Own `ProviderFile`" means a file whose `provider` is the client's own id (`openai`, `azure`, `requesty`, `gemini`; Vertex AI takes none). Several text parts with no media are sent as one string, joined by newlines, by the OpenAI-compatible providers and Ollama. llm4s refuses before sending only what would otherwise go silently wrong: a part a provider cannot send at all, a reference it cannot resolve, and llm4s's own bounds. A provider's own limits - how many images, how large a request, which formats a model reads - are the provider's to enforce: its refusal comes back as a typed error with its message. An agent thread holds at most 20 MiB of inline media across its messages (`Messages.MaxInlineBytes`). A message added past that keeps its text, and each of its inline parts that does not fit is replaced by a text part saying what was removed, so the model knows, and the run goes on. Earlier messages are never changed. ## Tools that return media A tool returns parts with `ToolContent.Parts`: (not compile-checked) ```scala import org.llm4s.agent.graph.tool.{ AgentTool, ToolContent, ToolOutcome } val plot = AgentTool(plotSpec) { (args, _) => ContentPart.image(MediaSource.Inline(IArray.from(render(args))), MediaType.Png) match { case Right(image) => ToolOutcome.Success(ToolContent.Parts(Vector(ContentPart.Text("The plot:"), image))) case Left(error) => ToolOutcome.Error(error.message) } } ``` `ToolContent.Json(value)` is the JSON result a tool returned before. The loop puts `Parts` in the call's `ToolMessage` unchanged. A result with no content, or with more than 5 MiB of inline bytes in total, is the call's error result instead, so the model is told and the run continues. The live `ToolCallResult` event carries the text view only. ## Stored conversations The JSON of a one-text message is unchanged, and JSON written before content parts reads as one text part, so existing memory stores, saved conversations and agent threads keep working. The semantic cache (`CachingLLMClient`) embeds a prompt's text, so it passes a conversation with media straight to the model. --- Source: https://llm4s.org/guide/java-rag # RAG from Java Ingest documents and ask questions about them from Java with `llm4s-java-rag`: answers come with the chunks they were drawn from. --- ## What `llm4s-java-rag` is Retrieval-augmented generation (RAG) answers a question from your own documents: the documents are split into chunks, each chunk is embedded as a vector, and a question is answered by a model that is given the chunks that match it best. `llm4s-java-rag` puts the [RAG pipeline](vector-store) of `llm4s-rag` behind one Java class, `JRag`: you hand it the embedding and chat clients of the [Java facade](java), ingest files or text, and `ask`. Every call returns an `LlmResult`, as `JLlmClient`'s do, so a failure is a value you check, not an exception. It is a module of its own, not part of `llm4s-java-api`, because `llm4s-rag` brings document parsing (Tika, POI, PDFBox), the AWS S3 SDK and the SQLite and Postgres drivers: a Java application that only calls a model does not receive them. It is **Experimental**: its API may change in any release ([1.0 Scope](../reference/v1-scope)). > `llm4s-java-rag` is new and is published from the release after 0.5.1. Until then, > build it from `main` ([Unreleased changes](../getting-started/installation#unreleased-changes-main)). ## Add the dependency The artifact is `org.llm4s:llm4s-java-rag_3`; it brings `llm4s-java-api` and `llm4s-rag` with it. **Maven** ```xml org.llm4s llm4s-java-rag_3 0.5.1 ``` **Gradle (Kotlin DSL)** ```kotlin dependencies { implementation("org.llm4s:llm4s-java-rag_3:0.5.1") } ``` **sbt** (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-java-rag" % "0.5.1" ``` It needs JDK 21, like `llm4s-java-api`. ## Build a pipeline A pipeline needs an embedding client; to `ask`, a chat client too. With both configured in `application.conf` (see [Embeddings](java#embeddings) and [Configure a provider](java#configure-a-provider)): ```java JRag rag = JRag.builder() .embeddings(Llm4s.createDefaultEmbeddingClient().get()) .llm(Llm4s.createDefaultClient().get()) .build() .get(); ``` The imports are `org.llm4s.javaapi.rag.*` for `JRag` and its types, and `org.llm4s.javaapi.*` for `Llm4s`, `LlmResult` and the clients. Any `JEmbeddingClient` and `JLlmClient` will do: one from `Llm4s.createEmbeddingClient`, from a [builder](java#configure-in-code), or a Scala client wrapped with `Llm4s.wrapClient`. The pipeline embeds every chunk as a document and every question as a query, with the embedding client's model; keep using the same model for a store, since vectors from two models cannot be compared. `build()` sends no request and never throws. An embedding client or store left out, or a setting out of range, is a failed result of kind `VALIDATION` that names the field. ## Ingest documents `ingest(Path)` reads a file, or every file directly in a directory whose name ends in `.txt`, `.md`, `.pdf`, `.docx`, `.json`, `.xml` or `.html`, and returns how many chunks it indexed. `ingestText` indexes text you already have, under an id you choose. Both take metadata, attached to every chunk: ```java int chunks = rag.ingest(docs).get(); chunks += rag.ingestText("Refunds are paid within 14 days.", "refund-policy", Map.of("team", "billing")).get(); ``` A file's id is its name. Ingesting an id again replaces what was indexed under it, so re-ingest a document after it changes. `documentCount()` and `chunkCount()` say what this pipeline has indexed. ## Ask a question `ask` embeds the question, finds the chunks that match it best and asks the model to answer from them alone. The answer is a `JRagAnswer`: its text, the question, and its `sources()`, best match first: ```java JRagAnswer answer = rag.ask("How long do refunds take?").get(); System.out.println(answer.answer()); for (JRagSource source : answer.sources()) { System.out.println(" " + source.documentId() + " (" + source.score() + "): " + source.content()); } ``` A `JRagSource` has the chunk's `content()`, the `documentId()` it came from, its `score()` and its `metadata()`: what you attached, plus `docId` and `chunkIndex`, and for a file its name as `source` and its absolute `path`. A score compares the sources of one search, not of two. `usage()` is the tokens the model call used, when the provider reports them. With nothing indexed, an answer has no sources and the model is asked anyway, so it says it cannot tell. A question is answered from at most five chunks; set another number with the builder's `topK`, or for one question with `ask(question, topK)`. The model is told to answer from the chunks alone and to say when they are not enough; replace those instructions with the builder's `systemPrompt`. ## Search without a model `search` returns the chunks `ask` would answer from, without calling a model, so it works on a pipeline built with no chat client: ```java List sources = rag.search("refunds", 3).get(); ``` The search is hybrid: a chunk's score fuses its vector similarity to the question with a keyword match, so a rare word in the question (a product name, an error code) finds its chunk even when the embedding model does not know it. ## Choose a store Chunks are kept in memory unless you say otherwise, and are lost when the pipeline is closed. `JRag.sqlite` keeps them in SQLite files, which a later pipeline over the same file reads again: ```java JRag rag = JRag.builder() .embeddings(embedder) .llm(client) .store(JRag.sqlite(dir.resolve("docs.db"))) // and docs-fts.db beside it .chunking(800, 150) .topK(5) .build() .get(); ``` The vectors go in `docs.db` and the keyword index in `docs-fts.db`. `chunking(size, overlap)` splits documents into chunks of about `size` characters, each repeating the last `overlap` characters of the one before; chunks end at a sentence where they can, and a chunk shorter than 100 characters is merged into its neighbour. The defaults are 800 and 150. Postgres and pgvector, reranking and GraphRAG are available from Scala (see [Vector store](vector-store)) but not yet from Java. ## Close it `JRag` is `AutoCloseable`: closing it releases its store. It does not close the clients it was built with, which may serve other pipelines; close those yourself. ```java try (JRag rag = JRag.builder().embeddings(embedder).llm(client).build().get()) { rag.ingest(docs).get(); System.out.println(rag.ask("How long do refunds take?").get().answer()); } ``` Different documents may be ingested from several threads at once; the same document id from two threads at once is not supported. ## Handling a failure Every call blocks the calling thread and never throws. A failure is a failed `LlmResult` whose `getError().getKind()` says what went wrong, as for the [Java facade](java#handling-a-failure): ```java LlmResult result = rag.ask("How long do refunds take?"); if (result.isFailure()) { LlmException error = result.getError(); switch (error.getKind()) { case VALIDATION -> System.err.println("Fix the call: " + error.getMessage()); case AUTHENTICATION, CONFIGURATION -> System.err.println("Fix the setup: " + error.getMessage()); case RATE_LIMIT -> System.err.println("Try again later"); default -> System.err.println("Failed: " + error.getMessage()); } } ``` - `VALIDATION`: a `null` or blank argument, a `topK` below 1, metadata with a `null` key or value, or a path that does not exist. - `CONFIGURATION`: `ask` on a pipeline built without a chat client. - A failure of the embedding provider or the model has that failure's kind: `AUTHENTICATION`, `RATE_LIMIT`, `SERVICE`, `NETWORK`, `TIMEOUT`. A file that cannot be read is `OTHER`. - Interrupting the blocked thread is `CANCELLED`, with the thread's interrupt flag still set. ## Where next - [Java](java): the clients a pipeline is built from, and how to read a result. - [Vector store](vector-store) and [RAG query transformers](rag-query-transformers): the pipeline in Scala, with every store and option. - The sample `org.llm4s.samples.rag.JavaRagExample` in `modules/samples`. --- Source: https://llm4s.org/guide/spark # Using LLM4S from Spark and Databricks Enrich a DataFrame with LLM calls from a Spark job: one client per partition, retries and a rate limit from `ReliableClient`, and failed calls kept as data instead of failing the stage. 1. TOC --- **Verified: 2026-10-21**, with llm4s 0.5.1. The example job in [`modules/samples/spark`](https://github.com/llm4s/llm4s/tree/main/modules/samples/spark) was run in Spark local mode (`local[2]`) against a local fake model: | Spark | Scala | JDK | Result | |---|---|---|---| | 4.0.1 | 2.13.16 | 21 and 17 | runs, enriched rows below | | 3.5.9 | 2.13.16 (see [Spark 3.5](#spark-35-and-databricks-164-lts)) | 21 | runs locally | Not verified on a Databricks cluster. What the Databricks release notes say about the runtimes is under [Databricks](#databricks). ## The Scala version route Spark and Databricks run Scala 2.13 (or 2.12); LLM4S is published for Scala 3 only. A Scala 2.13 job reads LLM4S's Scala 3 artifacts with the TASTy reader, which works for calling LLM4S but not for every API: see [Using LLM4S from Scala 2.13](../getting-started/scala-2-13) for the setup and what CI checks on every change. In short, completions, streaming, configuration, `ReliableClient`, agents and running tools work; defining tools with `ToolBuilder`, `completeStructured` and implementing `LLMClient` do not compile from 2.13, and `llm4s-java-api`'s `JTool` and `JLlmClient.complete(prompt, Record.class)` stand in for the first two. **Scala 2.12 is not supported**: it has no TASTy reader, so a Scala 2.12 runtime (Databricks 16.4 LTS's 2.12 image and earlier) cannot use LLM4S. ## Dependencies (not compile-checked) ```scala // build.sbt of the Spark job scalaVersion := "2.13.16" // the Spark job, not LLM4S doc-support: ignore scalacOptions += "-Ytasty-reader" libraryDependencies ++= Seq( "org.apache.spark" %% "spark-sql" % "4.0.1" % Provided, ("org.llm4s" %% "llm4s-core" % "0.5.1").cross(CrossVersion.for2_13Use3), ("org.llm4s" %% "llm4s-openai" % "0.5.1").cross(CrossVersion.for2_13Use3) // the provider module you call ) ``` Ship LLM4S and its dependencies in the job's assembly; Spark itself is `Provided`. ### Dependency conflicts: what was checked Resolving `spark-sql_2.13:4.0.1` together with `llm4s-core_3` and `llm4s-ollama_3` 0.5.1 (coursier, 176 artifacts): the two trees share only two artifacts. | Artifact | Spark 4.0.1 | LLM4S 0.5.1 | Resolved | |---|---|---|---| | `scala-library` | 2.13.16 | 2.13.16 (through `scala3-library_3` 3.7.1) | 2.13.16 | | `slf4j-api` | 2.0.16 | 2.0.17 | 2.0.17 (on a cluster, Spark's 2.0.16 is on the classpath; both are SLF4J 2) | Everything else LLM4S brings is a `_3` artifact Spark does not use (`scala3-library_3`, `cats-core_3`, `upickle_3`, `ujson_3`, `pureconfig-core_3`) plus Typesafe Config and jtokkit. Two cautions: - **Do not add the `_2.13` build of a library LLM4S uses as `_3`** (cats, uPickle, pureconfig) to the same job: both have the same class names, so one silently shadows the other at run time. - **`scala-library` must be 2.13.16 or later** at run time, the version LLM4S's Scala 3 standard library is built against. On a cluster the runtime's own `scala-library` wins over the job's: see the next section. ### Spark 3.5 and Databricks 16.4 LTS Spark 3.5.9 resolves `scala-library` 2.13.8, and Databricks Runtime 16.4 LTS's Scala 2.13 image ships 2.13.10. The example ran locally on Spark 3.5.9 only because the local classpath resolved `scala-library` up to LLM4S's 2.13.16; on a cluster, where the runtime's 2.13.8 or 2.13.10 is used, that is not verified, and code compiled against 2.13.16's library can fail with a `NoSuchMethodError`. Prefer Spark 4.0, which ships 2.13.16. ## Databricks From the Databricks release notes (read 2026-10-21): - [Databricks Runtime 17.3 LTS](https://docs.databricks.com/aws/en/release-notes/runtime/17.3lts): Apache Spark 4.0.0, Scala 2.13.16, Java 17 (Zulu 17). This matches the verified route: Spark 4, Scala 2.13.16, and the example also ran on JDK 17. - [Databricks Runtime 16.4 LTS](https://docs.databricks.com/aws/en/release-notes/runtime/16.4lts): Apache Spark 3.5.2, in a Scala 2.12 image (not usable, above) and a Scala 2.13 image with `scala-library` 2.13.10 (see [Spark 3.5](#spark-35-and-databricks-164-lts)). To call a model served by Databricks itself, its serving endpoints accept OpenAI-format chat requests, which the generic `openai-compatible` provider sends ([providers](providers)); this was not verified against a workspace. Authenticating with workload identity rather than a token is tracked in [#1354](https://github.com/llm4s/llm4s/issues/1354). ## The job The full job is [`EnrichReviews.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/spark/EnrichReviews.scala). Its shape: (not compile-checked) ```scala def enrich(reviews: Iterator[Review]): Iterator[EnrichedReview] = clientForPartition() match { case Left(error) => reviews.map(r => EnrichedReview(r.id, r.text, None, Some(s"no client: $error"))) case Right(client) => try reviews.map { review => client.complete(conversationFor(review), CompletionOptions().withMaxTokens(64)) match { case Right(completion) => EnrichedReview(review.id, review.text, Some(completion.content), None) case Left(error) => EnrichedReview(review.id, review.text, None, Some(error.message)) } }.toList.iterator // finish every call before the client is closed finally client.close() } val enriched = reviews.mapPartitions(enrich) ``` ### Client lifecycle - **Build the client on the executor, once per partition**, inside `mapPartitions`. An `LLMClient` holds an HTTP client and is not serialisable, so it cannot be built on the driver and shipped; building one per row would reload the configuration and set up a connection for every call. - **Close it when the partition is done**, in a `finally`. Force the iterator first (`.toList.iterator` above): `mapPartitions` is lazy, and closing the client before Spark pulls the rows would close it under the calls. - **Configuration is read on the executor.** `Llm4sConfig.defaultProvider()` reads the executor's `application.conf` (bundle it in the job's jar) and system properties, and the provider's API key from its conventional environment variable (`OPENAI_API_KEY`, ...), which must be set on the executors, not only the driver. ### Errors `complete` returns a `Result`, so a failed call is a value. The example turns it into an `error` column, so one bad row or a provider outage does not fail the stage and lose the partition's other rows; filter or retry those rows afterwards. Throwing instead would make Spark retry the whole task (`spark.task.maxFailures`, 4 by default), repeating every successful call in the partition. ### Concurrency and rate limits The calls run one at a time within a partition, so the job makes as many concurrent calls as partitions running at once (`spark.executor.cores` x executors, or the `n` of `local[n]`). Bound it with `repartition` or `coalesce` before `mapPartitions`. `ReliableClient` wraps each partition's client with retries, a deadline and a local rate limit: (not compile-checked) ```scala new ReliableClient( underlying, "spark-example", ReliabilityConfig( retryPolicy = RetryPolicy.fixedDelay(maxAttempts = 2, delay = 50.millis), rateLimit = RateLimitConfig(enabled = true, requestsPerMinute = 600, burstCapacity = 10), deadline = Some(30.seconds) ) ) ``` The rate limit is a token bucket **per client, so per partition**: the job's total is roughly `requestsPerMinute` times the partitions running at once. Divide the provider's limit by that number. A rate-limited call that still fails after its retries comes back as a `RateLimitError` in the `error` column. ## Run the example It is a [Scala CLI](https://scala-cli.virtuslab.org/) project, outside the sbt build and never published, so Spark stays out of every LLM4S artifact: ```bash cd modules/samples/spark scala-cli run . --main-class EnrichReviews -- --fake ``` `--fake` starts a local stand-in for Ollama that upper-cases each review and fails the one containing "boom". The output, on Spark 4.0.1: ``` +---+----------------------------------------+---------------------------------+-----------------------------------------------------------+ |id |text |answer |error | +---+----------------------------------------+---------------------------------+-----------------------------------------------------------+ |1 |The battery lasts two days |THE BATTERY LASTS TWO DAYS |NULL | |2 |Screen cracked in a week |SCREEN CRACKED IN A WEEK |NULL | |3 |boom: this one makes the fake model fail|NULL |Service error from ollama: the fake model failed (HTTP 500)| |4 |Fast delivery, works as described |FAST DELIVERY, WORKS AS DESCRIBED|NULL | +---+----------------------------------------+---------------------------------+-----------------------------------------------------------+ 4 rows, 1 with an error ``` Without `--fake`, the job calls the provider your `application.conf` names in `llm4s.providers.provider`. --- Source: https://llm4s.org/guide/embeddings # Embeddings Turn text into vectors: pick a provider and a model, create an `EmbeddingClient`, and embed documents and queries. --- ## 1. What embeddings are for An embedding model maps a text to a vector of numbers so that texts with a similar meaning end up close together. LLM4S uses them for [RAG](vector-store), for [agent memory](agents/memory) and for the [embedding cache](caching). Every one of them goes through the same type, `EmbeddingClient` in `llm4s-core`, so what you learn here applies to all three. ## 2. Choosing a provider and a model A provider is a module you add as a dependency, and a model is one of the provider's models. The module's `reference.conf` binds the environment variables for its `llm4s.embeddings.` block and, where it needs one, the vendor's key. | Provider id | Artifact | What it needs | Block variables (`llm4s.embeddings.`) | |---|---|---|---| | `openai` | `llm4s-openai` | `OPENAI_API_KEY` | `OPENAI_EMBEDDING_BASE_URL`, `OPENAI_EMBEDDING_MODEL` | | `ollama` | `llm4s-ollama` | a running Ollama, no key | `OLLAMA_EMBEDDING_BASE_URL`, `OLLAMA_EMBEDDING_MODEL` | | `voyage` | `llm4s-voyage` | `VOYAGE_API_KEY` | `VOYAGE_EMBEDDING_BASE_URL`, `VOYAGE_EMBEDDING_MODEL` | | `jina` | `llm4s-jina` | `JINA_API_KEY` | `JINA_EMBEDDING_BASE_URL`, `JINA_EMBEDDING_MODEL` | | `cohere` | `llm4s-cohere` | `COHERE_API_KEY` | `COHERE_EMBEDDING_BASE_URL`, `COHERE_EMBEDDING_MODEL` | Some starting points: a hosted general-purpose model (`openai/text-embedding-3-small`), a local one with no key and no data leaving the machine (`ollama/nomic-embed-text`), or a provider whose models distinguish queries from documents (`voyage`, `jina` and `cohere`, see [section 5](#5-documents-and-queries)). Without the provider's module on the classpath its id fails with an error naming the embedding providers that are registered. ## 3. Configuring one Embeddings are configured apart from chat providers. Select the provider and model with `llm4s.embeddings.model`, in `provider/model` form, which `llm4s-core` binds to `EMBEDDING_MODEL`: ```bash export EMBEDDING_MODEL=openai/text-embedding-3-small export OPENAI_API_KEY=sk-... ``` The key, a base URL and a second account's key follow the same rules as for chat providers, described in [Configuration](../getting-started/configuration#embeddings-configuration); it is not repeated here. ## 4. Embedding some texts `Llm4sConfig.embeddings()` reads those settings and returns the provider id with its `EmbeddingProviderConfig`; `EmbeddingClient.from` builds the client from them. An `EmbeddingRequest` carries the texts and an `EmbeddingModelConfig` naming the model and its dimensions, and the `EmbeddingResponse` holds one vector per text, in order: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.EmbeddingClient import org.llm4s.llmconnect.config.{ EmbeddingModelConfig, ModelDimensionRegistry } import org.llm4s.llmconnect.model.{ EmbeddingRequest, EmbeddingResponse } import org.llm4s.model.ModelRegistryService import org.llm4s.types.Result def embedTexts(texts: Seq[String]): Result[EmbeddingResponse] = for { settings <- Llm4sConfig.embeddings() providerId = settings._1 config = settings._2 registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry dimensions <- ModelDimensionRegistry.getDimension(providerId, config.model) client <- EmbeddingClient.from(providerId, config) response <- client.embed(EmbeddingRequest(texts, EmbeddingModelConfig(config.model, dimensions))) } yield response ``` Reading the response: ```scala import org.llm4s.llmconnect.model.EmbeddingResponse def describe(response: EmbeddingResponse): String = { val vectors = response.embeddings // one Seq[Double] per input text val size = vectors.headOption.map(_.size).getOrElse(0) val tokens = response.usage.map(_.totalTokens.toString).getOrElse("not reported") s"${vectors.size} vectors of $size dimensions, tokens: $tokens" } ``` `usage` and `dim` are optional: a provider that does not report them leaves them `None`. [`EmbeddingExample`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/embeddingsupport/EmbeddingExample.scala) runs this against files and prints the vectors. ## 5. Documents and queries Some embedding models are trained to embed a search query differently from the document it should find. An `EmbeddingRequest` says which side a text is on with its `purpose`, an `InputPurpose`: `Document` (the default) or `Query`. Voyage, Jina and Cohere map it onto their own parameter; OpenAI and Ollama embed both alike and ignore it. The table of what each provider sends is in [Queries and documents](../getting-started/configuration#queries-and-documents). Index your chunks as documents and embed the question as a query: ```scala import org.llm4s.llmconnect.EmbeddingClient import org.llm4s.llmconnect.config.EmbeddingModelConfig import org.llm4s.llmconnect.model.{ EmbeddingRequest, EmbeddingResponse, InputPurpose } import org.llm4s.types.Result def indexAndSearch( client: EmbeddingClient, model: EmbeddingModelConfig, chunks: Seq[String], question: String ): Result[(EmbeddingResponse, EmbeddingResponse)] = for { indexed <- client.embed(EmbeddingRequest(chunks, model)) queried <- client.embed(EmbeddingRequest(Seq(question), model, InputPurpose.Query)) } yield (indexed, queried) ``` A RAG query should be embedded as a query because the vectors in the store were made as documents. A provider that distinguishes the two aligns them for you only if each side says what it is; a query sent as a document still gives a vector, and it quietly retrieves worse. The RAG pipeline in `llm4s-rag` already does this. ## 6. Dimensions Every model produces vectors of one size, and a store is created for one size. `ModelDimensionRegistry` answers which, from the dimensions each provider declares for its models: ```scala import org.llm4s.llmconnect.config.ModelDimensionRegistry import org.llm4s.types.Result def dimensionsOf(provider: String, model: String): Result[Int] = ModelDimensionRegistry.getDimension(provider, model) ``` For a provider or model it does not know, the result is a `Left(ConfigurationError)`, not a guess. When a store already holds vectors of another size, because the model changed since they were written, the store refuses: the SQLite vector store, for one, answers a search with a `ProcessingError` that reads `Dimension mismatch: query vector has N dimensions, but stored vectors have M dimensions`. Embedding with a new model means re-embedding what is stored. Check the sizes up front rather than at the first search: ```scala import org.llm4s.error.ValidationError import org.llm4s.llmconnect.model.EmbeddingResponse import org.llm4s.types.Result def checkDimensions(response: EmbeddingResponse, storeDimensions: Int): Result[EmbeddingResponse] = response.embeddings.headOption.map(_.size) match { case Some(size) if size != storeDimensions => Left(ValidationError("dimensions", s"the model returned $size dimensions, the store expects $storeDimensions")) case _ => Right(response) } ``` ## 7. Errors Nothing is thrown: `embed` and `EmbeddingClient.from` return a `Result`, so a failure is a `Left` holding an `LLMError` (see [Error Handling](error-handling)). A failure the provider reports, such as a rejected key or a refused request, is an `EmbeddingError`, with an optional `code` (typically the HTTP status), a `message` and the `provider` that produced it. A configuration problem, such as an unset `EMBEDDING_MODEL` or a provider whose module is missing, is a `ConfigurationError` from `Llm4sConfig.embeddings()` instead (and an unregistered provider id given to `EmbeddingClient.from` is reported as an `EmbeddingError` whose provider is `config`). ```scala import org.llm4s.error.LLMError import org.llm4s.llmconnect.model.EmbeddingError def explain(error: LLMError): String = error match { case EmbeddingError(Some("401"), _, provider) => s"$provider rejected the API key" case EmbeddingError(code, message, provider) => s"$provider failed (${code.getOrElse("no code")}): $message" case other => other.formatted } ``` Because the errors travel in `Result`, a chain of steps stops at the first one that fails, as in the for-comprehensions above. ## Next steps - [Vector Store](vector-store): store the vectors and search them - [Configuration](../getting-started/configuration#embeddings-configuration): keys, base URLs and the other settings of an embeddings block - [Writing a Provider](writing-a-provider): add an embedding provider of your own --- Source: https://llm4s.org/guide/custom-tools # Build Your Own Tool Write a tool with `ToolBuilder`: declare its parameters, read them safely, return a result or an error, and test it without a model. --- The [built-in tools](builtin-tools) are written with `ToolBuilder`, the same way yours is, so this page is the how-to for the thing most users do first: write a tool. Everything here is in `llm4s-core` (`org.llm4s.toolapi`); running the tool in an agent needs `llm4s-agent`. ## 1. Anatomy of a tool A tool is a `ToolFunction` with four parts, and `ToolBuilder` assembles them: - a **name**, which is how the model calls it (`convert_temperature`); - a **description**, which is the model's only guide to when to call it; - a **parameter schema**, built with the `Schema` DSL, which tells the model the arguments it may send; - a **handler**, a function from a `SafeParameterExtractor` to `Either[String, R]`: `Right` is the result, `Left` is an error message. ```scala import org.llm4s.toolapi.{ SafeParameterExtractor, Schema, ToolBuilder, ToolFunction } import org.llm4s.types.Result def temperatureTool: Result[ToolFunction[Map[String, Any], String]] = { val schema = Schema .`object`[Map[String, Any]]("Temperature to convert") .withProperty(Schema.property("value", Schema.number("The temperature to convert, in the unit given by `unit`"))) .withProperty( Schema.property( "unit", Schema.string("The unit `value` is in").withEnum(Seq("celsius", "fahrenheit")) ) ) .withProperty( Schema.property( "precision", Schema.integer("Digits after the decimal point, 0 to 4 (default 1)").withRange(Some(0), Some(4)), required = false ) ) def handler(params: SafeParameterExtractor): Either[String, String] = for { value <- params.getDouble("value") unit <- params.getString("unit") precision <- params.getOptionalInt("precision").left.map(_.getMessage) converted <- unit match { case "celsius" => Right(value * 9 / 5 + 32) case "fahrenheit" => Right((value - 32) * 5 / 9) case other => Left(s"unit must be 'celsius' or 'fahrenheit', not '$other'") } } yield BigDecimal(converted).setScale(precision.getOrElse(1), BigDecimal.RoundingMode.HALF_UP).toString ToolBuilder[Map[String, Any], String]( "convert_temperature", "Converts a temperature between Celsius and Fahrenheit. Use it whenever the user asks for a temperature " + "in the other unit; it does not convert any other quantity.", schema ).withHandler(handler).buildSafe() } ``` `ToolBuilder[T, R]` takes the schema's type `T` (`Map[String, Any]` for an object whose fields you read one by one) and the result type `R`. `R` needs a upickle `ReadWriter`, because the result is written to JSON for the model: `String`, numbers and `Seq`s have one, and for your own case class you add `given ReadWriter[Result] = macroRW`. `buildSafe()` returns a `Result`, a `Left(ValidationError)` if you forgot `withHandler`, so building a tool never throws. ## 2. Declaring parameters with `Schema` `Schema` builds each parameter's type; `Schema.property(name, schema, required = true)` names it: | You need | Use | |---|---| | text | `Schema.string("...")`, with `.withEnum(Seq("a", "b"))` for a fixed set of values | | a whole number | `Schema.integer("...")`, with `.withRange(Some(min), Some(max))` | | a decimal number | `Schema.number("...")` | | true or false | `Schema.boolean("...")` | | a list | `Schema.array("...", itemSchema)` | | a value that may be `null` | `Schema.nullable(schema)` | | a nested object | ``Schema.`object` `` with a type parameter and a description, plus its own properties | An object schema adds fields with `withProperty`, or with `withRequiredField(name, schema)` and `withOptionalField(name, schema)`, which are shorthand for it. A nested object is read with a dotted path: ```scala import org.llm4s.toolapi.{ ObjectSchema, SafeParameterExtractor, Schema, ToolParameterError } def lookupSchema: ObjectSchema[Map[String, Any]] = Schema .`object`[Map[String, Any]]("A place to look up") .withRequiredField( "location", Schema .`object`[Map[String, Any]]("Where to look") .withRequiredField("city", Schema.string("City name")) .withOptionalField("country", Schema.string("Two-letter country code")) ) .withOptionalField("tags", Schema.array("Labels to match", Schema.string("One label"))) def city(params: SafeParameterExtractor): Either[String, String] = params.getString("location.city") def country(params: SafeParameterExtractor): Either[ToolParameterError, Option[String]] = params.getOptionalString("location.country") ``` Optional fields have a catch: `toOpenAITool` and `ToolRegistry.getOpenAITools` default to `strict = true`, which lists every property as required in the JSON schema it sends. Read an optional parameter with `getOptional*` either way, so the handler copes whether or not the model sent it, and call `toOpenAITool(strict = false)` where a provider should see the `required = false` flag. ## 3. Reading arguments `SafeParameterExtractor` reads one argument by name: `getString`, `getInt`, `getDouble`, `getBoolean`, `getArray` and `getObject`, each returning `Either[String, A]`, so they chain in a for-comprehension that stops at the first problem. `getOptionalString`, `getOptionalInt`, `getOptionalDouble` and `getOptionalBoolean` return `Either[ToolParameterError, Option[A]]`: an absent or `null` argument is `Right(None)`, and only a wrong type fails. Map that error with `.left.map(_.getMessage)` as the handler above does. A missing or mistyped argument does not throw; it is a `Left` with a message written for the model to act on: | Arguments | Message | |---|---| | `value` absent | `required parameter 'value' (type: number) is missing (available: unit)` | | `value` is `"hot"` | `parameter 'value' has wrong type - expected number but got string` | | `value` is `null` | `parameter 'value' (type: number) is required but value was null` | The message names what was expected and what arrived, and lists the argument names that were sent, so the model can correct itself and call again. The `*Enhanced` getters and `ToolFunction.executeEnhanced` return the same problems as structured `ToolParameterError`s, and `ToolCallErrorJson` renders them as JSON. ## 4. Returning a result or an error The handler's `Right` value is written to JSON and handed to the model; a `Left(message)` becomes a `ToolCallError.HandlerError`. Return a `Left` for anything the model can fix or should know: a bad argument, a missing record, a refused action. The model never sees a stack trace: - `tool.execute(args)` returns `Either[ToolCallError, ujson.Value]`, and a `Left(message)` from the handler is a `Left(HandlerError(toolName, message))`. - Run through an agent, the model is shown `error.getFormattedMessage`, which here reads `Tool call 'convert_temperature' failed with error: unit must be 'celsius' or 'fahrenheit', not 'kelvin'`. - An exception your handler throws is not a `Left` you chose. A `ToolRegistry` catches it and reports a `ToolCallError.ExecutionError` with the exception's message; calling `tool.execute` directly lets it escape. Keep expected failures in the `Left`. ## 5. Registering the tool and giving it to an agent A `ToolRegistry` holds tools by name and runs them from a `ToolCallRequest`. The agent takes the registry when you build it. This is what [`MultiToolExample`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/toolapi/MultiToolExample.scala) does with two tools: ```scala import org.llm4s.agent.Agent import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMConnect import org.llm4s.model.ModelRegistryService import org.llm4s.toolapi.{ ToolFunction, ToolRegistry } import org.llm4s.types.Result def askWithTool(tool: ToolFunction[?, ?], question: String): Result[Option[String]] = for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) agent <- Agent.builder("assistant", client).withTools(new ToolRegistry(Seq(tool))).build() result <- agent.run(question) } yield result.answer ``` The registry can also run a call itself, which is how the agent loop does it, and print the definitions a provider receives: ```scala import org.llm4s.toolapi.{ ToolCallError, ToolCallRequest, ToolFunction, ToolRegistry } def callByName(tool: ToolFunction[?, ?]): Either[ToolCallError, ujson.Value] = { val registry = new ToolRegistry(Seq(tool)) println(registry.getOpenAITools().render(indent = 2)) // what the model is told about the tool registry.execute(ToolCallRequest(tool.name, ujson.Obj("value" -> 100, "unit" -> "celsius"))) } ``` A name that is not in the registry is a `ToolCallError.UnknownFunction` that lists the tools that are. To add your tool next to the built-in ones, see [Built-in Tools](builtin-tools#8-your-own-tools); for how agents use tools, see [Agents](agents/#agent-with-tools). ## 6. Testing a tool on its own A tool is a function from JSON to JSON, so test it without a model or an agent: call `execute` with a `ujson.Obj` and assert on the `Either`. `MCPEmbeddedServerRoundTripSpec` builds its compact tools this way. ```scala import org.llm4s.toolapi.{ ToolCallError, ToolFunction } def checkTemperatureTool(tool: ToolFunction[Map[String, Any], String]): Unit = { // the result is the handler's `Right` value, written as JSON assert(tool.execute(ujson.Obj("value" -> 100, "unit" -> "celsius")) == Right(ujson.Str("212.0"))) assert(tool.execute(ujson.Obj("value" -> 100, "unit" -> "celsius", "precision" -> 3)) == Right(ujson.Str("212.000"))) // a missing argument is a HandlerError whose message names it tool.execute(ujson.Obj("unit" -> "celsius")) match { case Left(ToolCallError.HandlerError(name, message)) => assert(name == "convert_temperature") assert(message.contains("'value'") && message.contains("missing")) case other => assert(false, s"expected a HandlerError, got $other") } // a wrong type is one too tool.execute(ujson.Obj("value" -> "hot", "unit" -> "celsius")) match { case Left(ToolCallError.HandlerError(_, message)) => assert(message.contains("wrong type")) case other => assert(false, s"expected a HandlerError, got $other") } // so is the handler's own refusal assert(tool.execute(ujson.Obj("value" -> 1, "unit" -> "kelvin")).isLeft) } ``` Build the tool with `temperatureTool` from section 1 and pass the `Right` value in. The same calls work in ScalaTest with `shouldBe`; nothing here needs a provider or a network. ## 7. Three common mistakes **A vague description.** The description and the parameter descriptions are all the model has. `"Does stuff"` and `"the input"` give it nothing to choose the tool or fill the argument with. Say what the tool does, when to use it and when not to, and what each parameter means, including its unit: ```scala import org.llm4s.toolapi.{ Schema, ToolBuilder } def vagueTool = ToolBuilder[Map[String, Any], String]( "tool1", "Does stuff", Schema.`object`[Map[String, Any]]("Params").withRequiredField("input", Schema.string("the input")) ).withHandler(params => params.getString("input")).buildSafe() ``` **An undeclared parameter.** The model only sends arguments the schema declares. A handler that reads `params.getString("unit")` while the schema lists only `value` passes every test that supplies `unit` by hand, then fails in production with `required parameter 'unit' (type: string) is missing`. Check what the model is told: ```scala import org.llm4s.toolapi.{ Schema, ToolBuilder } def declaredParameters: Either[org.llm4s.error.LLMError, List[String]] = ToolBuilder[Map[String, Any], String]( "convert_temperature", "Converts a temperature", Schema.`object`[Map[String, Any]]("Temperature").withRequiredField("value", Schema.number("The temperature")) ).withHandler(params => params.getString("unit")) // reads `unit`, which the schema never declares .buildSafe() .map(tool => tool.toOpenAITool()("function")("parameters")("properties").obj.keys.toList) ``` The list is `List("value")`: add every parameter the handler reads to the schema. **A blocking call with no limit.** A handler that waits on a slow service holds the thread the agent loop is running on, and one hung call stalls the whole turn. Run the registry with a timeout so a slow tool comes back as an error the model can see: ```scala import org.llm4s.toolapi.{ ToolCallError, ToolCallRequest, ToolExecutionConfig, ToolRegistry } import scala.concurrent.ExecutionContext.Implicits.global import scala.concurrent.duration.* def callWithLimit(registry: ToolRegistry, request: ToolCallRequest): Either[ToolCallError, ujson.Value] = registry.execute(request, ToolExecutionConfig(timeout = Some(5.seconds))) ``` A call that runs past the limit returns `Left(ToolCallError.Timeout(name, 5.seconds))`. Give the underlying client its own timeout as well, so the work behind the handler stops too. ## Related - [Built-in Tools](builtin-tools): the tools that ship with `llm4s-agent-tools` - [Agents](agents/): running tools in an agent loop, with guardrails and handoffs - [Error Handling](error-handling): `Result[A]` and `LLMError` - [Tool Calling API design](../tool-calling-api-design): the design behind `ToolFunction`, errors and execution --- Source: https://llm4s.org/guide/java-reactor # Streaming to Reactor from Java Stream a model's reply as a Reactor `Flux` with `llm4s-reactor`, for Spring WebFlux and other reactive pipelines. --- ## What `llm4s-reactor` is The [Java facade](java#streaming-the-reply)'s `JLlmClient.streamAsPublisher` returns the reply's text as a JDK `java.util.concurrent.Flow.Publisher`. `llm4s-reactor` adapts it to Reactor: `Streams.toFlux` takes a `JLlmClient` and a query, a conversation, or a conversation and `JCompletionOptions`, and returns a `Flux` you can compose, return from a WebFlux handler, or `block()` on in a test. It is a module of its own, not part of `llm4s-java-api`, so that a Java application without Reactor does not receive `reactor-core`. It is **Experimental**: its API may change in any release ([1.0 Scope](../reference/v1-scope)). > `llm4s-reactor` is new and is published from the release after 0.5.1. Until then, > build it from `main` ([Unreleased changes](../getting-started/installation#unreleased-changes-main)). ## Add the dependency The artifact is `org.llm4s:llm4s-reactor_3`; it brings `llm4s-java-api` and `reactor-core` with it. **Maven** ```xml org.llm4s llm4s-reactor_3 0.5.1 ``` **Gradle (Kotlin DSL)** ```kotlin dependencies { implementation("org.llm4s:llm4s-reactor_3:0.5.1") } ``` **sbt** (not compile-checked) ```scala libraryDependencies += "org.llm4s" %% "llm4s-reactor" % "0.5.1" ``` It needs JDK 21, like `llm4s-java-api`. ## Stream a reply Build a client as the [Java guide](java#configure-a-provider) does, then ask for a `Flux`: ```java Flux reply = Streams.toFlux(client, "Explain monads in one sentence"); String text = reply .doOnNext(System.out::print) .collect(Collectors.joining()) .block(Duration.ofMinutes(1)); ``` The `Flux` emits the pieces of the reply's text in order, then completes. It is cold: nothing is sent until it is subscribed to, and each subscription makes its own provider call, on a thread of its own, so `block()` and a WebFlux response each start one. A piece with no text is skipped, and tool-call deltas are not streamed. In a WebFlux controller, return it from a handler that produces `text/event-stream` and each piece reaches the browser as it arrives. ## A conversation and options `toFlux` takes the same arguments as `JLlmClient.streamAsPublisher`: a conversation, and the options of a `JCompletionOptions` builder. ```java Conversation conversation = ConversationBuilder.create() .system("You are a concise assistant.") .user("Name three JVM languages.") .build(); JCompletionOptions options = JCompletionOptions.builder().temperature(0.2).maxTokens(200).build(); List pieces = Streams.toFlux(client, conversation, options).collectList().block(); ``` ## Backpressure and stopping early The `Flux` honours backpressure. A subscriber slower than the model holds the provider's thread rather than losing text: up to 64 pieces wait for it, and then the model's thread waits too. Cancelling the subscription - `dispose()`, an operator such as `take` or `timeout` that has what it needs, or a client that disconnects from a WebFlux response - interrupts the provider call, which is how llm4s providers stop: the call ends when its next piece arrives after the cancel. ```java List firstFive = Streams.toFlux(client, "Write a long story").take(5).collectList().block(); ``` ## Handling a failure A failed call ends the `Flux` with `onError`, after every piece that streamed before the failure, carrying an `LlmException` whose `getKind()` says what went wrong, as in the [Java guide](java#handling-a-failure). `toFlux` never throws: a `null` client, query, conversation or options is a `Flux` that fails on subscription with kind `VALIDATION`. ```java String text = Streams.toFlux(client, "Hello") .collect(Collectors.joining()) .onErrorResume(LlmException.class, e -> switch (e.getKind()) { case RATE_LIMIT -> Mono.just("Busy, try again later"); default -> Mono.just("Failed: " + e.getMessage()); }) .block(); ``` ## Other reactive libraries Only Reactor has a module. RxJava 3 (`FlowAdapters` in `org.reactivestreams`), Mutiny (`Multi.createFrom().publisher(...)`) and Kotlin's `Publisher.asFlow()` adapt `JLlmClient.streamAsPublisher`'s JDK `Flow.Publisher` themselves, with no llm4s module. Agent event streams are not adapted yet; see [streaming a turn](agents/streaming#java-and-kotlin). ## Where next - [Java](java): the client, conversations, options and how to read a failure. - [Spring Boot](spring-boot): auto-configuration that gives a WebFlux application its `JLlmClient`. --- Source: https://llm4s.org/guide/http-reference-apps # Serving an Agent over HTTP `modules/examples-http` holds small, tested reference apps that expose an llm4s agent from a Scala HTTP stack. Each serves the same two endpoints, so they can be compared: | Endpoint | Request | Response | |---|---|---| | `POST /chat` | `{"message": "..."}` | `{"threadId", "status", "answer"}` as JSON, or an error status | | `POST /chat/stream` | `{"message": "..."}` | server-sent events of the answer as it is generated | | App | Built on | Status | |---|---|---| | http4s (Ember) | [`llm4s-effect`](cats-effect): `AgentIO[IO]`, an fs2 `Stream` | available | | ZIO HTTP (Netty) | [`llm4s-zio`](zio): `AgentZ`, `ZStream` | available | | Tapir, served by http4s (Ember) | endpoint definitions interpreted by `tapir-http4s-server`, over `AgentIO[IO]`; OpenAPI docs at `/docs` | available | The module is not published: it is code to read and copy. Its HTTP dependencies stay in it. The apps share what does not depend on the stack: the request and event JSON (`ChatProtocol`), the error mapping (`HttpErrors`), the command line (`ServerSettings`) and the agent's configuration (`ChatAgent`). ## Run it With the fake client, which needs no model and no API key (it answers "You said: ..."): ```bash sbt "examplesHttp/run --fake" ``` With a model: the app loads the provider at startup through `Llm4sConfig`, from the named section `llm4s.providers.provider` names. The module's `application.conf` uses the samples' default, a local Ollama (`ollama pull llama3` first, or set `OLLAMA_MODEL`), and has `openai-main` and `anthropic-main` sections that use `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`: ```bash sbt examplesHttp/run LLM4S_PROVIDER=openai-main sbt examplesHttp/run # with OPENAI_API_KEY set ``` `run` starts the http4s app; the ZIO HTTP and Tapir apps take the same arguments through `runMain`: ```bash sbt "examplesHttp/runMain org.llm4s.examples.http.ziohttp.ZioChatServerMain --fake" LLM4S_PROVIDER=openai-main sbt "examplesHttp/runMain org.llm4s.examples.http.ziohttp.ZioChatServerMain" sbt "examplesHttp/runMain org.llm4s.examples.http.tapir.TapirChatServerMain --fake" ``` `--port N` and `--host H` change the address (default `127.0.0.1:8080`). Then: ```bash curl -s localhost:8080/chat -d '{"message": "What is Scala?"}' curl -N localhost:8080/chat/stream -d '{"message": "What is Scala?"}' ``` ## The stream `POST /chat/stream` answers `200` with `Content-Type: text/event-stream` once the request is valid, then sends: | Event | Data | |---|---| | `delta` | `{"attempt", "text"}`: the next piece of the answer. A higher `attempt` means the model call was retried; discard the earlier attempt's text. | | `tool_call` | `{"id", "tool"}`: the agent is calling a tool. | | `done` | the `/chat` response body; always the last event of a run that ends. | | `error` | the error body below, plus `status` (and `retryAfterSeconds`): the run failed. | A `: keep-alive` comment is sent every 15 seconds while the run is quiet. The http4s app (`ChatEvents`) turns `AgentIO.stream` - the run's events, then its result - into `ServerSentEvent`s: (not compile-checked) ```scala agent .stream(ThreadId(UUID.randomUUID().toString), message) .collect { case AgentStreamItem.Event(AgentEvents.TextDelta(d)) => event("delta", ChatProtocol.delta(d.attempt, d.text)) case AgentStreamItem.Event(AgentEvents.ToolCallStarted(t)) => event("tool_call", ChatProtocol.toolCall(t.toolCallId, t.tool)) case AgentStreamItem.Done(result) => event("done", ChatProtocol.result(result)) } .handleErrorWith(error => Stream.emit(event("error", ChatProtocol.streamError(HttpErrors.of(llmError(error)))))) .mergeHaltL(Stream.awakeEvery[IO](heartbeat).as(ServerSentEvent(comment = Some("keep-alive")))) ``` The ZIO HTTP app does the same with `AgentZ.stream`, writing each event in the SSE format itself, so the keep-alive can be a comment: (not compile-checked) ```scala agent .stream(ThreadId(UUID.randomUUID().toString), message) .collect { case AgentStreamItem.Event(AgentEvents.TextDelta(d)) => event("delta", ChatProtocol.delta(d.attempt, d.text)) case AgentStreamItem.Event(AgentEvents.ToolCallStarted(t)) => event("tool_call", ChatProtocol.toolCall(t.toolCallId, t.tool)) case AgentStreamItem.Done(result) => event("done", ChatProtocol.result(result)) } .catchAllCause(cause => ZStream.succeed(event("error", ChatProtocol.streamError(HttpErrors.of(llmError(cause)))))) .mergeHaltLeft(ZStream.fromSchedule(Schedule.spaced(heartbeat)).as(": keep-alive\n\n")) ``` The response is that stream as a chunked body (`Body.fromCharSequenceStreamChunked`) with `Content-Type: text/event-stream`. The Tapir app serves the http4s app's stream (`ChatEvents`), encoded with http4s's `ServerSentEvent.encoder`, as a Tapir streaming body. It does not use `tapir-http4s-server`'s `serverSentEventsBody`, because sttp's `ServerSentEvent` has no comment field for the keep-alive. See [The Tapir app](#the-tapir-app). Both agents are built with `withStreaming()`, so its model calls stream and their text reaches the run's events as `AgentEvents.TextDelta`. ## Cancellation When the client disconnects, the run is cancelled and the provider call interrupted. The server learns of the disconnect when a write fails; the heartbeat makes a write happen at least every 15 seconds, even while the model is silent. The failed write interrupts the response stream, and interrupting `AgentIO.stream` cancels the run (`AgentRun.cancel`), which interrupts its model call. `Http4sChatServerSpec` shows it: a provider call that parks after its first chunk is interrupted once the test's socket closes. The Tapir app hands the same stream to Ember through `tapir-http4s-server`, so a disconnect cancels the run in the same way; `TapirChatServerSpec` makes the same check. In the ZIO HTTP app, the server interrupts the response stream when the connection closes, or when a write fails; interrupting `AgentZ.stream` cancels the run in the same way. `ZioChatServerSpec` makes the same check. `POST /chat` runs the turn with `AgentIO.run` or `AgentZ.run`, each of which cancels the turn when its fiber is cancelled. Ember does not watch a connection while it computes a response, so with the http4s app the turn of a client that leaves `/chat` runs to the end; use `/chat/stream` when that matters, or bound the run with a timeout (`RunConfig().withBudgets(RunBudgets().withTimeout(...))`). ## The Tapir app `ChatEndpoints` defines the two endpoints as values, independent of any server, with the errors as a typed output: `ErrorResponse` (the status, `Retry-After` in seconds, the error body), one `oneOf` variant per status `HttpErrors.of` can choose, so the documentation lists each one. (not compile-checked) ```scala val chat: PublicEndpoint[String, ErrorResponse, ujson.Value, Any] = endpoint.post .in("chat") .in(requestBody) .out(jsonBody(summon[Schema[ChatResponse]]).description("The turn's result.")) .errorOut(oneOf[ErrorResponse](variant(StatusCode.BadRequest), ErrorStatuses.tail.map(variant)*)) val chatStream: PublicEndpoint[String, ErrorResponse, Stream[IO, Byte], Fs2Streams[IO]] = endpoint.post .in("chat" / "stream") .in(requestBody) .out(header(HeaderNames.CacheControl, "no-cache")) .out(streamTextBody(Fs2Streams[IO])(CodecFormat.TextEventStream(), Some(StandardCharsets.UTF_8))) .errorOut(oneOf[ErrorResponse](variant(StatusCode.BadRequest))) ``` The `429` variant adds the `Retry-After` header. The request body is read as text and checked by `ChatProtocol.message`, so an invalid body gets the shared `400` error body instead of Tapir's own decode-failure response. The bodies are the shared protocol's `ujson` values; the case classes in `ChatSchemas` (`derives Schema`) only describe them for the documentation. `TapirChatRoutes` gives the endpoints their logic, the same as the http4s app's, and interprets them with `Http4sServerInterpreter`: (not compile-checked) ```scala val chat = ChatEndpoints.chat.serverLogic[IO] { body => ChatProtocol.message(body) match { case Left(invalid) => IO.pure(Left(ErrorResponse.of(HttpErrors.of(invalid)))) case Right(message) => agent.run(message).attempt.map { case Right(result) => Right(ChatProtocol.result(result)) case Left(error) => Left(ErrorResponse.of(HttpErrors.of(ChatEvents.llmError(error)))) } } } val chatStream = ChatEndpoints.chatStream.serverLogicPure[IO] { body => ChatProtocol.message(body).left.map(invalid => ErrorResponse.of(HttpErrors.of(invalid))) .map(message => ChatEvents(agent, message, heartbeat).through(ServerSentEvent.encoder[IO])) } val routes = Http4sServerInterpreter[IO]().toRoutes(List(chat, chatStream) ++ docs) ``` It is served on the http4s app's Ember server. Tapir 1.11.20 is the last release built on the cats-effect (3.5.7), fs2 (3.11.0) and http4s (0.23.30) versions the module uses; a later Tapir needs cats-effect 3.6. ### The OpenAPI documentation The same endpoint values are interpreted as an OpenAPI document (`OpenAPIDocsInterpreter`), so the documentation cannot drift from what is served. With the Tapir app running: - `http://localhost:8080/docs` - the Swagger UI, where both endpoints can be tried; - `http://localhost:8080/docs/openapi.yaml` - the OpenAPI 3.1 document. ```bash sbt "examplesHttp/runMain org.llm4s.examples.http.tapir.TapirChatServerMain --fake" curl -s localhost:8080/docs/openapi.yaml ``` The document has both endpoints, the request, answer and error body schemas, and every error status with its description; the `429` response documents `Retry-After`. To watch the stream's events as they arrive, use `curl -N` rather than the Swagger UI. ## Errors An error is answered as `{"error": {"type", "message", "recoverable"}}`. `HttpErrors.of` chooses the status from the error that failed the run, unwrapping the agent runtime's `GraphError.NodeFailed`: | Error | Status | |---|---| | a body that is not `{"message": ""}`, `ValidationError`, `InvalidInputError` | `400` | | `RateLimitError` | `429`, with `Retry-After`: the provider's hint, else `RateLimitError.DefaultRetryDelay` | | `TimeoutError` | `504` | | `AuthenticationError` | `502`: the provider refused the server's credential, which the caller cannot fix | | `ConfigurationError` | `500` | | any other `RecoverableError` (network, provider 5xx) | `503` | | anything else | `502` | The message is the library's, which can name the provider; a public service may want to log it and send less. ## Testing The tests need no API key and no network beyond the loopback. `ChatRoutesSpec` calls the routes in memory against a `ScriptedLLMClient` from [`llm4s-testkit`](testing-with-the-testkit): the JSON answer, the order of the streamed events, each error's status, and the stream's `error` event. `Http4sChatServerSpec` starts the server on an ephemeral port and calls it over real sockets, including the disconnect above. `ZioChatRoutesSpec` (with `Routes.runZIO`) and `ZioChatServerSpec` check the ZIO HTTP app the same way, and `TapirChatRoutesSpec` and `TapirChatServerSpec` the Tapir app. `ChatEndpointsSpec` checks the generated OpenAPI document: both endpoints, their schemas, every error status, and `Retry-After` on the `429` response. ```bash sbt examplesHttp/test ``` ## What a real service adds These apps leave out authentication, rate limiting, request size limits, conversation history (each request starts a new thread in the agent's in-memory store) and deployment. See [Production Deployment](../PRODUCTION_DEPLOYMENT) for closing the client and the rest of running llm4s in production. --- Source: https://llm4s.org/guide/quarkus # Using LLM4S with Quarkus Call a model from a Quarkus application: a CDI producer for the `JLlmClient`, its settings in `application.properties`, and a REST endpoint that keeps the blocking call off the event loop. --- There is no Quarkus extension for llm4s. This page wires the [Java API](java) into Quarkus with plain CDI and MicroProfile Config, which is all a first application needs. Every snippet is a file of the runnable sample, [`modules/samples/quarkus-java`](https://github.com/llm4s/llm4s/tree/main/modules/samples/quarkus-java), which CI builds and tests against the library on every pull request. ## Run the sample You need **JDK 21 or newer** and **Maven 3.9 or newer**. The sample's default provider is a local [Ollama](../getting-started/ollama-quickstart) server at `http://localhost:11434`, which needs no key: ```bash ollama pull llama3.2 cd modules/samples/quarkus-java mvn quarkus:dev -Dllm4s.version=0.5.1 ``` Then ask it something: ```bash curl -X POST -H 'Content-Type: text/plain' -d 'What is Quarkus, in one sentence?' http://localhost:8080/chat ``` The reply is the model's answer as plain text. Without `-Dllm4s.version` the build uses `0.1.0-SNAPSHOT`, the code of your checkout, which you first publish to your local Maven repository with `sbt publishM2` from the repository root, after setting the build's version to `0.1.0-SNAPSHOT` as the sample's README shows. `mvn verify` runs the sample's tests, which need neither Ollama nor a key: they start a stub Ollama server on a loopback port (see [Testing](#testing)). ## Dependencies llm4s is Scala 3 only, and Maven has no notion of Scala cross-versions, so the artifact is written with its Scala binary suffix, `llm4s-java-api_3`. It brings `llm4s-core`, the agent runtime, the provider modules and the Scala 3 library: ```xml org.llm4s llm4s-java-api_3 ${llm4s.version} ``` Next to the Quarkus BOM, the sample pins `org.scala-lang:scala3-library_3` to the version llm4s was built with in `dependencyManagement`, by name: Scala 3 runs on the Scala 2.13 `scala-library`, so never pin the whole `org.scala-lang` group. The Quarkus BOM pins Jackson to its own version, newer than the one the OpenAI and Anthropic SDKs ask for; the sample's tests build both clients with it. Quarkus routes SLF4J to its own log manager (`slf4j-jboss-logmanager`), so llm4s's logs appear in the Quarkus log with no logging backend added. Do not add `logback-classic`, as a plain Java application would: two SLF4J providers make SLF4J pick one and warn. ## Configuration The client's settings are four `llm4s.*` keys in `application.properties`, read by a `@ConfigMapping` interface. Quarkus maps `baseUrl()` to `llm4s.base-url` and `apiKey()` to `llm4s.api-key`, and each key can also be set as a system property or an environment variable (`LLM4S_PROVIDER`, `LLM4S_MODEL`, `LLM4S_BASE_URL`, `LLM4S_API_KEY`): ```java @ConfigMapping(prefix = "llm4s") public interface Llm4sSettings { /** Which client to build: {@code ollama}, {@code openai}, {@code anthropic} or {@code openai-compatible}. */ @WithDefault("ollama") String provider(); /** The model name, as the provider spells it. */ String model(); /** {@code llm4s.base-url}: the provider's endpoint; each provider has a default except {@code openai-compatible}. */ Optional baseUrl(); /** {@code llm4s.api-key}: required by {@code openai} and {@code anthropic}, unused by {@code ollama}. */ Optional apiKey(); } ``` ```properties llm4s.provider=ollama llm4s.model=llama3.2 ``` So `LLM4S_PROVIDER=openai LLM4S_MODEL=gpt-4o-mini LLM4S_API_KEY=sk-... mvn quarkus:dev` runs the same application against OpenAI. Keep a real key out of `application.properties`: use the environment variable, or a `.env` file that is not committed. These keys are the sample's own and are separate from llm4s's HOCON configuration (`application.conf`, the [Configuration guide](../getting-started/configuration)): the client is built with the Java API's builders, which take every setting as an argument and read no provider section. An application that already keeps its provider in `application.conf` can build the client with `Llm4s.createDefaultClient()` in the producer instead. ## The client as a CDI bean A producer method makes the `JLlmClient` an application-scoped bean, built from the settings, and a disposer method closes it when the application stops. A setting llm4s refuses, such as a missing key, fails the first injection with llm4s's message: ```java @ApplicationScoped public class LlmClientProducer { @Produces @ApplicationScoped JLlmClient llmClient(Llm4sSettings settings) { LlmResult client = build(settings); if (client.isFailure()) { // a missing key 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(); } void close(@Disposes JLlmClient client) { client.close(); } static LlmResult build(Llm4sSettings settings) { String baseUrl = settings.baseUrl().orElse(null); String apiKey = settings.apiKey().orElse(null); return switch (settings.provider()) { case "ollama" -> { var builder = Llm4s.ollama().model(settings.model()); yield (baseUrl == null ? builder : builder.baseUrl(baseUrl)).build(); } case "openai" -> { var builder = Llm4s.openAi().model(settings.model()).apiKey(apiKey); yield (baseUrl == null ? builder : builder.baseUrl(baseUrl)).build(); } case "anthropic" -> { var builder = Llm4s.anthropic().model(settings.model()).apiKey(apiKey); yield (baseUrl == null ? builder : builder.baseUrl(baseUrl)).build(); } case "openai-compatible" -> { var builder = Llm4s.openAiCompatible().model(settings.model()).baseUrl(baseUrl); yield (apiKey == null ? builder : builder.apiKey(apiKey)).build(); } default -> throw new IllegalStateException("Unknown llm4s.provider '" + settings.provider() + "': use ollama, openai, anthropic or openai-compatible"); }; } } ``` `JLlmClient` is a final class with no public constructor, which CDI cannot normally proxy for a normal scope such as `@ApplicationScoped`. Quarkus's ArC handles it at build time (`quarkus.arc.transform-unproxyable-classes`, on by default); if you have turned that off, produce the client as `@Singleton` instead, which needs no proxy. One client serves every request: one `JLlmClient` can be used by many threads at once ([Sharing one client](java-threading-and-cancellation#sharing-one-client-or-agent)), and it holds an HTTP client that should be built once and closed once. ## Calling it from a REST resource `JLlmClient.complete` blocks the calling thread until the model answers, which can take seconds. Quarkus REST picks an endpoint's thread from its signature and may run it on a Vert.x event-loop thread (it does for one returning a `Uni`), where a blocking call stalls every request that thread serves. `@Blocking` states the choice instead of leaving it to the signature: the call runs on a worker thread. On JDK 21 `@RunOnVirtualThread` is the alternative, and each request then gets a virtual thread; the Ollama client does not pin the carrier thread while it waits ([Virtual threads](java-threading-and-cancellation#virtual-threads)). ```java @Path("/chat") public class ChatResource { @Inject JLlmClient client; // JLlmClient.complete blocks until the model answers, so it must not run on the Vert.x event loop: @Blocking puts // the call on a worker thread (@RunOnVirtualThread would put it on a virtual thread instead). @POST @Blocking @Consumes(MediaType.TEXT_PLAIN) @Produces(MediaType.TEXT_PLAIN) public Response chat(String question) { if (question == null || question.isBlank()) { return Response.status(Response.Status.BAD_REQUEST).entity("Send a question as the request body").build(); } LlmResult answer = client.complete(ConversationBuilder.create() .system("You are a concise assistant.") .user(question) .build()); if (answer.isSuccess()) { return Response.ok(answer.get()).build(); } LlmException error = answer.getError(); return Response.status(statusOf(error)).entity(error.getKind() + ": " + error.getMessage()).build(); } /** A failure is the model's, not the caller's: report it as a gateway error, keeping a rate limit visible. */ private static int statusOf(LlmException error) { return switch (error.getKind()) { case RATE_LIMIT -> 429; case TIMEOUT -> 504; default -> 502; }; } } ``` A failed call is an `LlmResult` value, not an exception, so the resource reads its [kind](java#handling-a-failure) and answers with a status: a rate limit stays a 429, a timeout becomes a 504, and anything else a 502, since the failure is the model's and not the caller's. ## Testing `@QuarkusTest` starts the application with the real producer and the real client, pointed at a stub. The sample's `OllamaStub` is a `QuarkusTestResourceLifecycleManager`: it starts a JDK `HttpServer` on a free loopback port that answers Ollama's `POST /api/chat` with a canned reply, and returns `llm4s.base-url` (with `llm4s.provider` and `llm4s.model`) as configuration for the test run. The test then calls `/chat` with REST Assured and checks the reply, the request the stub received, and that a stub failure comes back as a 502; no model and no key are involved. To test code that uses the client without any HTTP, inject a fake instead: `FakeLlmClient.replying("...").client()` from `llm4s-java-api` ([Testing your code](java#testing-your-code)) is a `JLlmClient`, which a test producer annotated `@Alternative @Priority(1)`, or a `@QuarkusTest` with `QuarkusMock.installMockForType`, can supply. ## What is not supported - **Native image.** `mvn package -Dnative` is not supported: llm4s ships no GraalVM reachability metadata yet. That work is the spike in [#1463](https://github.com/llm4s/llm4s/issues/1463). Run the application on the JVM. - **The agent API from Java.** This page uses `JLlmClient` and `ConversationBuilder` only; the Java agent API is still changing. - **Dev Services, Dev UI, health checks and metrics.** Nothing is registered for you. Add a health check of your own, or record metrics with `llm4s-observability-micrometer` over Quarkus's Micrometer registry ([Micrometer metrics](observability/micrometer)). ## What an extension would add A Quarkus extension (`quarkus-llm4s`) would replace the producer and the configuration interface above, and could add what plain CDI cannot: - **Build-time wiring**: the `llm4s.*` configuration and the producer generated by the extension, with a configuration error reported at build time instead of at first use. - **Dev Services**: an Ollama container started in dev and test mode when no base URL is configured, as Quarkus does for databases. - **Observability**: a health check calling the provider, and Micrometer metrics and OpenTelemetry traces wired from the Quarkus extensions already present. - **Native image**: the reflection and resource registrations llm4s needs, once [#1463](https://github.com/llm4s/llm4s/issues/1463) has found them. - **A Dev UI card** with the configured provider and a prompt box. Whether that is worth building depends on feedback from this guide: open an issue or a discussion if you use it. ## Where next - [Java](java): conversations, completion options, structured output and failures from Java. - [Java threading and cancellation](java-threading-and-cancellation): which calls block and how to cancel them. - [Spring Boot](spring-boot): the same client as a Spring Boot starter, with auto-configuration. - [Maven quick start](../getting-started/maven): the plain Java project this sample builds on. --- Source: https://llm4s.org/guide/data-protection # Data Protection What personal data llm4s moves, to where, what it keeps, and what it logs by default: the facts a records-of-processing entry, a DPIA or a data-residency review needs. --- > This page describes what the library does. It is not legal advice, and it does not describe what a vendor does > with data once it arrives: follow the vendor links, and your own agreements with each vendor, for that. ## In short - **llm4s sends no telemetry.** The library has no usage reporting, analytics or update check. Every network call it makes goes to a destination your configuration or code names: a provider, a store, a tracing backend, a tool's API. The [egress table](#network-destinations-for-an-egress-allow-list) lists them. - **The default tracer prints metadata only.** `llm4s.tracing.mode` defaults to `console`, which prints event types, models, ids, tool names, durations, token counts and costs to stdout, and withholds prompts, model output, tool input/output, custom event data and error messages unless `llm4s.tracing.console.captureContent = true`. - **Nothing is persisted unless you configure a store.** Agent threads, memories and vector indexes live in memory by default; SQLite, PostgreSQL, Qdrant, Elasticsearch/OpenSearch, Redis and Neo4j are opt-in modules you point at. - **Prompts go to the model provider you select, in the region its endpoint serves.** llm4s does not route between regions; the [residency table](#provider-data-residency-and-retention) shows the setting that picks the region. ## What leaves the process, and what is kept Each row is something a module does only when you use it. "Configured by" is where the destination comes from. | Module | What leaves the process | Destination | Configured by | Persisted by llm4s | |---|---|---|---|---| | Chat providers (`llm4s-openai`, `-anthropic`, `-gemini`, `-ollama`, `-openai-compatible`, `-bedrock`, `-watsonx`, `-jev`) | The conversation: system prompt, messages, tool definitions, tool calls and results, attached images and documents | The provider's API | `llm4s.providers.` (`baseUrl`, provider extras such as `endpoint`, `location`, `region`) | No | | Embeddings (`llm4s-openai`, `-ollama`, `-voyage`, `-jina`, `-cohere`) | The text being embedded: documents at indexing, queries at search | The embedding provider's API | `llm4s.embeddings.` | No | | Reranking (`llm4s-rag`) | The query and candidate passages | Cohere's rerank API (`CohereReranker`), or your chat provider (`LLMReranker`) | `llm4s.rerank.cohere`, or the client you pass | No | | Image generation and vision (`llm4s-image`) | Prompts and images | OpenAI, Stability AI, Hugging Face, a Stable Diffusion server you run; OpenAI, Anthropic or Gemini for vision | The client config you build | No | | Speech (`llm4s-speech`) | Audio (speech-to-text) or text (text-to-speech) | OpenAI, ElevenLabs or Azure Speech; Vosk, Whisper and Tacotron 2 run locally | `SPEECH_TTS_MODEL`, `SPEECH_STT_MODEL` and the selected provider's key | No | | Web search tools (`llm4s-agent-tools`) | The search query the model wrote, which can quote the user | Brave, Exa or DuckDuckGo | `llm4s.tools.*` | No | | HTTP tool (`llm4s-agent-tools`) | Whatever request the model builds | The URLs the tool's config allows | `HttpConfig` (private and cloud-metadata addresses are refused by default) | No | | MCP client (`llm4s-mcp`) | Tool arguments the model wrote | The MCP servers you register | Your `MCPServerConfig`s | No | | Workspace (`llm4s-workspace`) | Commands and files the agent works on | A container you run | `llm4s.workspace.*` | In the container's file system | | Agent runtime (`llm4s-agent`) | Nothing on its own | - | - | Thread history, events and usage in the `Checkpointer`: in memory by default, SQLite (`llm4s-agent-checkpoint-sqlite`) or PostgreSQL (`llm4s-agent-checkpoint-postgres`) when configured | | Memory (`llm4s-memory`, `-memory-postgres`) | Memory text to the embedding provider, when the store embeds | Your embedding provider | The store you build | Memories: in memory, SQLite or PostgreSQL | | RAG and vector stores (`llm4s-rag`, `-vectorstore-elasticsearch`, `-vectorstore-redis`) | Chunks, embeddings and metadata | pgvector, Qdrant, SQLite, Elasticsearch/OpenSearch or Redis | The store you build | Yes, in that store | | Knowledge graph (`llm4s-knowledgegraph-neo4j`) | Entities and relations extracted from text | Neo4j | The store you build | Yes, in Neo4j | | Response cache (`CachingLLMClient`, `CachedEmbeddingClient`) | Nothing | - | - | Completions and embeddings in process memory, until evicted or cleared | | Provider exchange logging (`llm4s-core`) | Nothing over the network | A JSON Lines file, or your sink | `llm4s.exchangeLogging` (off by default) | Raw request and response bodies, in the file | | Tracing backends (`llm4s-observability`, `-observability-otel`) | Trace events, including content (see below) | Langfuse (`https://cloud.langfuse.com` by default) or an OTLP collector (`http://localhost:4317` by default) | `llm4s.tracing.langfuse`, `llm4s.tracing.opentelemetry` | In the backend | | Metrics (`llm4s-observability-prometheus`) | Counters and histograms labelled by provider, model, operation, status and error type: no content | Scraped from the `/metrics` endpoint you start | `llm4s.metrics` | No | ## Logging and tracing defaults | What | Default | Content it records | |---|---|---| | `console` tracing | On (`llm4s.tracing.mode = "console"`) | None unless `llm4s.tracing.console.captureContent = true`; then the query, model output (truncated to 200 characters), tool input and output (100 each), custom event data and error messages | | `noop` tracing | Off | None | | `langfuse` tracing | Off | The agent's query, completion content, tool input and output, and a finished run's messages | | `opentelemetry` tracing | Off | None unless `llm4s.tracing.opentelemetry.captureContent = true` (`OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`); then the query, prompts and completions (`gen_ai.input.messages` / `gen_ai.output.messages`) and tool input and output, secrets redacted and each truncated to 1000 characters. Custom event data is always recorded (an agent's carries no content) | | Provider exchange logging | Off | Request and response bodies. The file sink redacts credentials by pattern, not personal data; a sink you write gets the bodies untouched. See [Provider Exchange Logging](observability/provider-exchange-logging) | | SLF4J logs from the OpenAI-compatible, Gemini and Vertex AI clients | At `DEBUG` only | Request and response bodies, truncated, with credentials redacted by pattern | Keep `captureContent` off, and `DEBUG` off for `org.llm4s`, wherever stdout and logs are collected into a system with other retention or access rules than your conversation store. To set it explicitly in code: ```scala import org.llm4s.config.Llm4sConfig import org.llm4s.trace.{ ConsoleTracing, NoOpTracing, Tracing } // What the configuration selects: console, metadata only, unless TRACING_MODE says otherwise val configured: Tracing = Llm4sConfig.tracing().map(Tracing.create).getOrElse(new NoOpTracing()) // Prompts and answers too: for a developer's machine only val verbose: Tracing = new ConsoleTracing(captureContent = true) ``` **What is redacted.** The exchange-log file sink and the debug logging above pass bodies through llm4s's internal redaction, which masks API keys of known formats, `Authorization`, `Cookie` and similar headers, credential-bearing URL query parameters and fields named like secrets (`api_key`, `password`, `token`, `client_secret`). It does not look for personal data: names, addresses, account numbers and free text pass through unchanged. `TracingSettings` and provider configs redact credentials in their `toString`. ## Network destinations for an egress allow-list Allow only what your configuration uses. The defaults below are what a section gets when it sets no `baseUrl`. | Destination | Default host | When | |---|---|---| | OpenAI | `api.openai.com` (or a residency host such as `eu.api.openai.com`, set as `baseUrl`) | `provider = "openai"`, OpenAI embeddings, speech or images | | Azure OpenAI | Your resource's `endpoint` | `provider = "azure"` | | Anthropic | `api.anthropic.com` | `provider = "anthropic"`, Anthropic vision | | Gemini API | `generativelanguage.googleapis.com` | `provider = "gemini"`, Gemini vision | | Vertex AI | `-aiplatform.googleapis.com`, plus `oauth2.googleapis.com` or the GCE metadata server for tokens | `provider = "vertexai"` | | AWS Bedrock | `bedrock-runtime..amazonaws.com` | `provider = "bedrock"` | | IBM watsonx.ai | `us-south.ml.cloud.ibm.com` and `iam.cloud.ibm.com` | `provider = "watsonx"` | | DeepSeek, Z.ai, OpenRouter, Mistral, Cohere, Requesty | `api.deepseek.com`, `api.z.ai`, `openrouter.ai`, `api.mistral.ai`, `api.cohere.ai`, `router.requesty.ai` | The matching provider | | Jev (TypeSafe) | `api.typesafe.ai` | `llm4s-jev` | | Voyage, Jina, Cohere embeddings and rerank | `api.voyageai.com`, `api.jina.ai`, `api.cohere.com` | The matching embedding provider or `CohereReranker` | | Ollama, generic `openai-compatible` | The `baseUrl` you set (Ollama: `localhost:11434`) | The matching provider | | Brave, Exa, DuckDuckGo | `api.search.brave.com`, `api.exa.ai`, `api.duckduckgo.com` | The search tools | | Stability AI, Hugging Face, ElevenLabs | `api.stability.ai`, `api-inference.huggingface.co`, `api.elevenlabs.io` | Image generation, text-to-speech | | Langfuse, OTLP | `cloud.langfuse.com`; `localhost:4317` | The tracing mode | | Model metadata | None: the registry is bundled | Only when you set `llm4s.modelRegistry.url` | ## Provider data residency and retention llm4s sends a request to the endpoint a provider section names and nothing else, so residency is chosen by that endpoint. llm4s sets no retention flag of its own on any request (OpenAI's `store`, for example, is never sent, so the API's default applies). Zero-data-retention and modified-abuse-monitoring arrangements are made with the vendor, on the account, and need no llm4s setting. | Provider | How the region is chosen in llm4s | Vendor documentation | |---|---|---| | OpenAI | `baseUrl = "https://eu.api.openai.com/v1"` (or `us.`) for a project created with that data residency | [Your data](https://developers.openai.com/api/docs/guides/your-data) | | Azure OpenAI | `endpoint` is your resource, whose region decides the geography; a Global or DataZone deployment may process elsewhere | [Data, privacy, and security](https://learn.microsoft.com/en-us/azure/ai-foundry/responsible-ai/openai/data-privacy) | | Anthropic | Not selectable: llm4s does not send `inference_geo`. For regional processing of Claude models use Bedrock | [API and data retention](https://platform.claude.com/docs/en/manage-claude/api-and-data-retention), [Data residency](https://platform.claude.com/docs/en/manage-claude/data-residency) | | Gemini API | Not selectable: one global endpoint. Paid and unpaid tiers differ in how Google uses prompts | [Gemini API terms](https://ai.google.dev/gemini-api/terms) | | Vertex AI | `location = "europe-west4"` (default `us-central1`): a regional endpoint | [Data residency](https://docs.cloud.google.com/vertex-ai/generative-ai/docs/learn/data-residency) | | AWS Bedrock | `region` (required) | [Data protection](https://docs.aws.amazon.com/bedrock/latest/userguide/data-protection.html) | | IBM watsonx.ai | `baseUrl`, such as `https://eu-de.ml.cloud.ibm.com` (default `https://us-south.ml.cloud.ibm.com`) | [Endpoint URLs](https://cloud.ibm.com/apidocs/watsonx-ai) | | Ollama | `baseUrl` of a server you run: data stays on it | [Ollama FAQ](https://docs.ollama.com/faq) | For example, a section that keeps processing in the EU on Vertex AI: ```hocon llm4s.providers.gemini-eu { provider = "vertexai" model = "gemini-2.0-flash" project = ${?VERTEXAI_PROJECT} location = "europe-west4" } ``` ## Deleting a user's data An erasure request reaches every store you configured. What llm4s offers per store: | Store | Delete by | Notes | |---|---|---| | Agent threads (`Checkpointer`) | Thread: `Agent.forget(threadId)` or `GraphRuntime.deleteThread` | Removes the thread's history, usage and event log, and its child threads. A deletion leaves a tombstone, reclaimed by a store sweep with `tombstoneAge`. `RetentionPolicy` (`maxAge`, `maxCheckpoints`, `maxEvents`) prunes old history but never a thread's latest checkpoint, so erasure needs `forget` | | Memories (`MemoryStore`) | `delete(id)`, `deleteMatching(filter)` | User facts carry `user_id` metadata (`MemoryFilter.ByMetadata("user_id", id)`); conversations `conversation_id` (`MemoryFilter.ByConversation`); entities `entity_id` (`MemoryFilter.ByEntity`) | | Vector stores (`VectorStore`) | `delete`, `deleteBatch`, `deleteByPrefix`, `deleteByFilter` | Only as findable as the metadata you indexed with: tag documents with the owner's id | | Exchange-log files, tracing backends, provider-side storage | Outside llm4s | Delete with the tool that stores them | llm4s keeps no index from a user to their threads: record which thread ids belong to which user in your application. ```scala import org.llm4s.agent.Agent import org.llm4s.agent.graph.ThreadId import org.llm4s.agent.memory.{ MemoryFilter, MemoryStore } import org.llm4s.types.Result import org.llm4s.vectorstore.{ MetadataFilter, VectorStore } def erase( agent: Agent, memories: MemoryStore, vectors: VectorStore, userId: String, threads: Seq[ThreadId] ): Result[Unit] = for { _ <- threads.foldLeft[Result[Unit]](Right(()))((done, thread) => done.flatMap(_ => agent.forget(thread))) _ <- memories.deleteMatching(MemoryFilter.ByMetadata("user_id", userId)) _ <- vectors.deleteByFilter(MetadataFilter.Equals("userId", userId)) } yield () ``` ## Personal data in prompts `llm4s-agent` has two guardrails that find personal data with regular expressions, before a query reaches the model and before an answer is returned: - `PIIMasker` replaces what it finds (`[REDACTED_EMAIL]`, `[REDACTED_CARD]`, ...) and lets the turn continue. - `PIIDetector` blocks the turn by default, or masks (`GuardrailAction.Fix`) or only warns (`GuardrailAction.Warn`; the warning names the types found, never the text). Both cover `SSN`, `CreditCard`, `Email` and `Phone` by default; `PIIType.all` adds `IPAddress`, `Passport` and `DateOfBirth`, and `BankAccount` can be listed explicitly. ```scala import org.llm4s.agent.Agent import org.llm4s.agent.graph.middleware.GuardrailMiddleware import org.llm4s.agent.guardrails.builtin.{ PIIDetector, PIIMasker } import org.llm4s.agent.guardrails.patterns.PIIPatterns.PIIType val guarded = Agent .builder("support", client) .withMiddleware( GuardrailMiddleware( input = Seq(PIIMasker(PIIType.all)), // mask before the model sees the query output = Seq(PIIDetector()) // block an answer that still contains PII ) ) .build() ``` Their limits: - The patterns are mostly US formats (SSN, US phone numbers, US-style dates); international phone numbers are matched only with a leading `+`. Names, postal addresses, IBANs in most layouts and free-text health data are not detected. - They see the query and the final answer. Tool arguments and tool results, retrieved RAG passages and memories put into the prompt are not checked, and neither is what a tool sends to its own API. - A masked query is what the thread stores, so the masking also keeps it out of the checkpointer; an unmasked one is stored as written. For stronger guarantees, redact or pseudonymise before text reaches llm4s, and choose a provider and region your agreements cover. ## See also - [Security reference](../reference/security) - threat model and data-flow overview - [Provider Exchange Logging](observability/provider-exchange-logging) - what the exchange log records - [Guardrails](agents/guardrails) - every built-in guardrail - [Configuration](../getting-started/configuration#environment-variables-llm4s-reads) - the variables llm4s reads --- Source: https://llm4s.org/guide/modular-rag-example # Modular RAG Example This guide introduces a practical, modular Retrieval-Augmented Generation (RAG) example for LLM4S. The sample lives in: - `modules/samples/src/main/scala/org/llm4s/samples/rag/modular/ModularRAGExample.scala` - `modules/samples/src/main/scala/org/llm4s/samples/rag/modular/RAGModules.scala` ## Architecture The example intentionally separates the pipeline into modules: 1. `IngestionModule`: document ingestion from filesystem (`.txt`, `.md`, `.pdf`, `.docx`) or direct text. 2. `RetrievalModule`: similarity retrieval (`topK`) from indexed chunks. 3. `GenerationModule`: grounded answer generation from retrieved contexts. This keeps cross-cutting concerns small and makes each stage easy to evolve or test independently. ## Run ### 1) Retrieval + generation (if LLM provider configured) ```bash sbt "samples/runMain org.llm4s.samples.rag.modular.ModularRAGExample" ``` If no document path is passed, the sample ingests a small built-in corpus. ### 2) Use your own document directory ```bash sbt "samples/runMain org.llm4s.samples.rag.modular.ModularRAGExample ./docs \"What are the key reliability patterns?\"" ``` The ingestion stage supports file extraction through the core RAG API, including PDF. ## Configuration The sample resolves embedding and LLM settings from `Llm4sConfig`: - Embeddings are required. - LLM is optional. If LLM config is missing, the sample still runs ingestion + retrieval and skips answer generation. ## Why this pattern This modular RAG layout is useful for real applications because you can: - swap retrieval strategy without changing ingestion/generation wiring, - test retrieval quality independently, - run retrieval-only workflows when generation is disabled or unavailable. --- Source: https://llm4s.org/examples/cookbook # Cookbook Complete programs for the things you would actually build, each on one page: the problem, the program, how to run it, what to change for a real provider, and the pitfalls. Every recipe is a small Scala program in [`modules/samples`](https://github.com/llm4s/llm4s/tree/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook), and every one runs with **no API key**: by default a scripted client stands in for the model, so you can see the whole flow work in seconds. Add `--live` to run the same code against the provider your configuration names (`llm4s.providers.provider`, see [running the samples](../getting-started/configuration#running-the-samples)). CI runs every recipe against its scripted client on every pull request, and checks that the program on each page is the source file, so what you read compiles and works. ## The recipes | Recipe | What it shows | |---|---| | [Classify text into an enum](cookbook/structured-output) | `completeStructured` reads a reply into a case class whose category is one of a fixed set | | [Extract structured data from an email](cookbook/email-extraction) | a nested schema with a list of line items, checked against the email | | [Summarise a long document](cookbook/summarise) | map-reduce: summarise each part, then the summaries | | [Answer questions over a folder of files](cookbook/folder-qa) | the RAG pipeline: ingest a folder, embed, retrieve, answer with sources | | [An agent that calls two tools](cookbook/tool-calling) | a tool of your own and the built-in calculator, chained by the model | | [A guardrailed chatbot](cookbook/guardrails) | input and output guardrails around an agent | | [Stream tokens to the terminal](cookbook/streaming) | `streamComplete`: print the reply as it is generated | | [Fall back between providers](cookbook/fallback) | `ReliableClient` retries, then a second provider | | [Cache repeated calls](cookbook/caching) | `CachingLLMClient`: answer a repeated question without a model call | | [Evaluate an answer with a judge](cookbook/judge) | LLM-as-judge: grade an answer against a reference, 1 to 5 | | [Answer questions with keyword search](cookbook/document-qa) | a BM25 keyword index, no embedding model | | [Remember facts between turns](cookbook/memory) | a memory manager feeding the system prompt | | [Several agents in one graph](cookbook/multi-agent-graph) | parallel specialist agents and an editor, joined in a graph | ## Running a recipe ```bash sbt "samples/runMain org.llm4s.samples.cookbook.StructuredOutputRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.StructuredOutputRecipe --live" # your configured provider ``` For `--live`, the samples default to a local Ollama model (`ollama pull llama3` first). To use another provider, add a section to the git-ignored `modules/samples/src/main/resources/application.local.conf` and select it: ```hocon llm4s.providers { openai-main { provider = "openai" model = "gpt-4o-mini" } } ``` ```bash export OPENAI_API_KEY=sk-... export LLM4S_PROVIDER=openai-main sbt "samples/runMain org.llm4s.samples.cookbook.StructuredOutputRecipe --live" ``` ## Adding a recipe A recipe is a good first contribution. Copy one of the files, give it its own `RecipeInfo`, add it to `Cookbook.apps` in `Recipe.scala`, and write its page in `docs/examples/cookbook/` with the program pasted in. `CookbookDocsSpec` then tells you what is missing: the page, its sections, its place in the navigation, the links from this page, the README and the examples index, or a program that differs from the source. For the samples that are not recipes, see the [examples index](index). --- Source: https://llm4s.org/examples/cookbook/structured-output # Classify text into an enum Turn a customer message into a `Ticket` whose category is one of a fixed set. ## The problem You want a label from a fixed list (an enum) and a couple of fields, not prose: route a support message to billing, bug or question, with an urgency. Parsing free text for that is fragile. `completeStructured` sends a JSON schema with the request and reads the reply into a case class, so the rest of the program deals in a `Ticket`, and a reply that does not fit is a `Left`, not an exception. ## The program The whole program, [`StructuredOutputRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/StructuredOutputRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.error.ValidationError import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, Conversation, UserMessage } import org.llm4s.toolapi.Schema import org.llm4s.types.Result import upickle.default.ReadWriter /** A support ticket, as the model is asked to produce it. */ final case class Ticket(category: String, urgency: Int, summary: String) derives ReadWriter /** * Recipe: classify free text into a typed value. * * `completeStructured` sends a JSON schema with the request and reads the reply into a case class, so the rest of the * program deals in a `Ticket` and never in a string. The category is an enum in the schema, and the recipe checks it * again on the way out, because only some providers enforce a schema while generating. * {{{ * sbt "samples/runMain org.llm4s.samples.cookbook.StructuredOutputRecipe" # scripted client, no API key * sbt "samples/runMain org.llm4s.samples.cookbook.StructuredOutputRecipe --live" # your configured provider * }}} */ object StructuredOutputRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "structured-output", title = "Classify text into an enum", summary = "Turn a customer message into a Ticket case class with completeStructured.", mainClass = "org.llm4s.samples.cookbook.StructuredOutputRecipe" ) val categories: Seq[String] = Seq("billing", "bug", "question") val message: String = "I was charged twice for my March invoice and need the second payment refunded today." val ticketSchema = Schema .`object`[Ticket]("A customer support ticket") .withRequiredField("category", Schema.string("The kind of ticket").withEnum(categories)) .withRequiredField("urgency", Schema.integer("From 1 (can wait) to 5 (urgent)").withRange(Some(1), Some(5))) .withRequiredField("summary", Schema.string("One sentence describing the problem")) def classify(client: LLMClient, text: String): Result[Ticket] = for { ticket <- client.completeStructured[Ticket](Conversation(Seq(UserMessage(text))), ticketSchema) _ <- Either.cond( categories.contains(ticket.category), (), ValidationError.invalid("category", s"'${ticket.category}' is not one of ${categories.mkString(", ")}") ) // The schema states the range, but a provider that ignores it can still answer outside it. _ <- Either.cond( 1 <= ticket.urgency && ticket.urgency <= 5, (), ValidationError.invalid("urgency", s"${ticket.urgency} is outside 1 to 5") ) } yield ticket /** Answers with the JSON a model following the schema would produce. */ def script: ScriptedClient = new ScriptedClient((_, _) => Right( AssistantMessage("""{"category": "billing", "urgency": 4, "summary": "Charged twice for the March invoice."}""") ) ) def demo(client: LLMClient): Result[String] = classify(client, message).map(t => s"category=${t.category} urgency=${t.urgency} summary=${t.summary}") } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.StructuredOutputRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.StructuredOutputRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Nothing in `classify` names a provider: pass it the client of your provider. With `--live` the recipe does that for you. In your own program, take the client from your configuration: (not compile-checked) ```scala for { providerConfig <- Llm4sConfig.defaultProvider() registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry client <- LLMConnect.getClient(providerConfig) ticket <- StructuredOutputRecipe.classify(client, text) } yield ticket ``` More on schemas and how each provider handles them: [Structured output](../../guide/structured-output). ## Pitfalls - Only some providers enforce a schema while generating (OpenAI and Gemini do; Anthropic gets a best-effort instruction). The recipe checks the category and the urgency again on the way out for that reason. - A model may wrap its JSON in a code fence or a sentence; `completeStructured` reads the JSON out of either. - Keep the enum short and the labels distinct. Two labels a person would confuse, the model confuses too. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/email-extraction # Extract structured data from an email Read an order change out of a free-text email into a case class with a list of line items. ## The problem Emails carry structured requests in unstructured text: who is asking, which order, what products, by when. You want those as values your code can act on, including a list whose length you do not know in advance. A nested schema (an object with an array of objects) describes that, and `completeStructured` reads the reply into `OrderRequest`. ## The program The whole program, [`EmailExtractionRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/EmailExtractionRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.error.ValidationError import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, Conversation, SystemMessage, UserMessage } import org.llm4s.toolapi.Schema import org.llm4s.types.Result import upickle.default.ReadWriter final case class LineItem(product: String, quantity: Int) derives ReadWriter /** What a customer asked for in an email, as the model is asked to produce it. */ final case class OrderRequest(customer: String, orderNumber: String, items: Seq[LineItem], deliverBy: String) derives ReadWriter /** Recipe: extract structured data, with a nested list, from an email; then check it against the email. */ object EmailExtractionRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "email-extraction", title = "Extract structured data from an email", summary = "Read an order request out of a free-text email into a case class with a list of line items.", mainClass = "org.llm4s.samples.cookbook.EmailExtractionRecipe" ) val email: String = """From: Dana Whitfield |Subject: Re: order HC-2291 | |Hi, could you change order HC-2291 to 12 bags of the house blend and 3 boxes of oat milk? |We need it by 14 November 2026 at the latest. Thanks, Dana (Harbour Cafe)""".stripMargin val orderSchema = Schema .`object`[OrderRequest]("An order request taken from an email") .withRequiredField("customer", Schema.string("The business that sent the email")) .withRequiredField("orderNumber", Schema.string("The order number exactly as written in the email")) .withRequiredField( "items", Schema.array( "Every product asked for", Schema .`object`[LineItem]("One product and how many") .withRequiredField("product", Schema.string("The product name")) .withRequiredField("quantity", Schema.integer("How many").withRange(Some(1), None)) ) ) .withRequiredField("deliverBy", Schema.string("The latest delivery date as YYYY-MM-DD, or \"\" if none is given")) def extract(client: LLMClient, email: String): Result[OrderRequest] = { val prompt = Conversation( Seq(SystemMessage("Extract the order request from the email. Use only what the email says."), UserMessage(email)) ) for { order <- client.completeStructured[OrderRequest](prompt, orderSchema) // A model can produce a well-formed value that is not in the text: check what you can against the source. _ <- Either.cond( email.contains(order.orderNumber), (), ValidationError.invalid("orderNumber", s"'${order.orderNumber}' does not appear in the email") ) _ <- Either.cond(order.items.nonEmpty, (), ValidationError.invalid("items", "the email asks for nothing")) } yield order } /** Answers with the JSON a model following the schema would produce for [[email]]. */ def script: ScriptedClient = new ScriptedClient((_, _) => Right(AssistantMessage("""{"customer": "Harbour Cafe", "orderNumber": "HC-2291", "deliverBy": "2026-11-14", |"items": [{"product": "house blend", "quantity": 12}, {"product": "oat milk", "quantity": 3}]}""".stripMargin)) ) def demo(client: LLMClient): Result[String] = extract(client, email).map { order => val items = order.items.map(item => s"${item.quantity} x ${item.product}").mkString(", ") s"${order.customer}, order ${order.orderNumber}: $items, deliver by ${order.deliverBy}" } } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.EmailExtractionRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.EmailExtractionRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Pass the client of your provider to `extract`, or run with `--live`. Long emails, threads and signatures cost tokens: strip quoted history before you send the text if you only need the latest message. A provider that enforces schemas (OpenAI, Gemini) returns well-formed JSON every time; the others are asked to. ## Pitfalls - A well-formed value is not a true one. A model can produce a plausible order number that is not in the email, so the recipe checks it against the text. Check whatever you can against the source. - Say what to do when a field is absent. The schema asks for `""` when there is no delivery date; without that the model invents one. (A required field is safer than an optional one: providers that enforce schemas in strict mode require every field.) - Ask for dates in one format (`YYYY-MM-DD`) and parse them yourself; "next Friday" needs the date the email was sent. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/summarise # Summarise a long document Cut a document into parts, summarise each part, then summarise the summaries (map-reduce). ## The problem A document longer than the context window, or long enough to make one call slow and expensive, cannot be summarised in one request. Map-reduce splits it: the sentence chunker cuts it into parts at sentence boundaries, each part is summarised on its own (map), and the part summaries, in document order, are summarised once more (reduce). ## The program The whole program, [`SummariseRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/SummariseRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import cats.syntax.traverse._ import org.llm4s.chunking.{ ChunkerFactory, ChunkingConfig } import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, Conversation, MessageRole, SystemMessage, UserMessage } import org.llm4s.types.Result /** A summary and how many parts the document was cut into. */ final case class Summary(text: String, parts: Int) /** Recipe: summarise a document longer than you want to send at once, by map-reduce. */ object SummariseRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "summarise", title = "Summarise a long document", summary = "Cut a document into parts, summarise each part, then summarise the summaries (map-reduce).", mainClass = "org.llm4s.samples.cookbook.SummariseRecipe" ) val MapPrompt: String = "Summarise this part of a longer document in one sentence. Keep names and numbers." val ReducePrompt: String = "These are summaries of the parts of one document, in order. Write a three-sentence summary." def summarise(client: LLMClient, document: String, partSize: Int = 250): Result[Summary] = { val chunking = ChunkingConfig(targetSize = partSize, maxSize = partSize * 3 / 2, overlap = 0, minChunkSize = 0) val parts = ChunkerFactory.sentence().chunk(document, chunking).map(_.content).toList def ask(instruction: String, text: String): Result[String] = client.complete(Conversation(Seq(SystemMessage(instruction), UserMessage(text)))).map(_.content) for { partial <- parts.traverse(part => ask(MapPrompt, part)) // map: one call per part summary <- partial match { // reduce: one more call, unless there was only one part case Seq(only) => Right(only) case several => ask(ReducePrompt, several.zipWithIndex.map((s, i) => s"${i + 1}. $s").mkString("\n")) } } yield Summary(summary, parts.size) } val report: String = Seq( "The northern warehouse shipped 18,400 orders in the third quarter, 12 percent more than in the second. " + "Most of the growth came from the new coffee subscription, which now makes up a quarter of all orders.", "Late deliveries rose from 2 to 5 percent in August, when one of the two carriers lost a sorting hub to a fire. " + "Moving the affected routes to the second carrier brought the rate back to 2 percent by mid September.", "Returns stayed flat, under 2 percent of orders. The team plans to add a second packing line before the holiday peak, " + "at a cost of 140,000 euros, and to hire six seasonal staff in November." ).mkString("\n\n") /** Answers a part with its first sentence, and the summaries with a fixed three-sentence summary. */ def script: ScriptedClient = new ScriptedClient((conversation, _) => { val instruction = conversation.messages.find(_.role == MessageRole.System).map(_.content).getOrElse("") val text = conversation.messages.filter(_.role == MessageRole.User).map(_.content).mkString Right( AssistantMessage( if (instruction == MapPrompt) text.takeWhile(_ != '.') + "." else "Orders grew 12 percent, " + "led by the coffee subscription. A carrier outage pushed late deliveries to 5 percent in August, since " + "fixed. A second packing line and six seasonal staff are planned before the holiday peak." ) ) }) def demo(client: LLMClient): Result[String] = summarise(client, report).map(summary => s"${summary.text}\n(from ${summary.parts} parts)") } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.SummariseRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.SummariseRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Pass your provider's client to `summarise`, or run with `--live`. Set `partSize` (in characters) to what suits your model: a few thousand characters per part is usual. The map calls are independent, so for a long document run them concurrently (with a `Future` per part, or the effect library you use) instead of in sequence as here. ## Pitfalls - Every part costs a call, and the reduce one more: a 100-part document is 101 calls. - The reduce step sees only the summaries. A detail one part's summary dropped is gone; tell the map prompt what must be kept (here: names and numbers). - When the part summaries together are still too long for one call, reduce in rounds: summarise them in groups, then summarise those. - Parts are cut at sentence boundaries with no overlap; a point made across two parts can be split. Add overlap in `ChunkingConfig` if that matters for your documents. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/folder-qa # Answer questions over a folder of files Ingest every file in a folder into the RAG pipeline, retrieve the passages for a question, and answer from them. ## The problem You have a folder of documents (here a three-file handbook) and want answers drawn from them, with the files they came from. `llm4s-rag`'s `RAG` pipeline reads every supported file in a folder (text, Markdown, PDF, Word and more), chunks and embeds it, keeps it in an in-memory vector store and keyword index, and answers a question from the passages that match best (hybrid search). The recipe runs offline: `BagOfWordsEmbeddings`, a stand-in embedding model in [`Recipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/Recipe.scala), turns text into word-count vectors without a network call. ## The program The whole program, [`FolderQaRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/FolderQaRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, MessageRole } import org.llm4s.llmconnect.spi.ProviderRegistry import org.llm4s.model.ModelRegistryService import org.llm4s.rag.{ RAG, RAGAnswerResult } import org.llm4s.types.{ Result, TryOps } import java.nio.charset.StandardCharsets import java.nio.file.{ Files, Path } import scala.util.Try /** Recipe: answer questions over a folder of files with the RAG pipeline (embeddings, hybrid search, an answer). */ object FolderQaRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "folder-qa", title = "Answer questions over a folder of files", summary = "Ingest every file in a folder into a RAG pipeline, retrieve the passages for a question, answer from them.", mainClass = "org.llm4s.samples.cookbook.FolderQaRecipe" ) def answer(client: LLMClient, folder: Path, question: String): Result[RAGAnswerResult] = { // The stand-in embedding model, registered like any provider module's; see "Use a real provider". given ProviderRegistry = ProviderRegistry.default.withEmbeddingProvider(BagOfWordsEmbeddings) for { models <- Llm4sConfig.modelRegistryService() given ModelRegistryService = models config = RAG .builder() .withEmbeddings(BagOfWordsEmbeddings.id.asString, BagOfWordsEmbeddings.Model, BagOfWordsEmbeddings.Dimensions) .withTopK(2) .withLLM(client) rag <- RAG.build(config, _ => Right(BagOfWordsEmbeddings.config)) result = rag.ingest(folder).flatMap(_ => rag.queryWithAnswer(question)) _ = rag.close() // closed whether the question was answered or not answer <- result } yield answer } val handbook: Map[String, String] = Map( "vacation.txt" -> "Employees receive 25 days of paid vacation per year. Unused days carry over until 31 March.", "expenses.txt" -> "Expenses above 50 euros need a receipt. Claims must be filed within 30 days.", "remote.txt" -> "Remote work is allowed up to three days a week with manager approval." ) /** Writes [[handbook]] to a new temporary folder, standing in for a folder of your own documents. */ def writeHandbook(): Result[Path] = Try { val folder = Files.createTempDirectory("cookbook-handbook") folder.toFile.deleteOnExit() // registered first, so deleted last, once its files are gone handbook.foreach { (name, text) => Files.write(folder.resolve(name), text.getBytes(StandardCharsets.UTF_8)).toFile.deleteOnExit() } folder }.toResult /** Answers with the first passage of the context the pipeline put in the prompt. */ def script: ScriptedClient = new ScriptedClient((conversation, _) => { val prompt = conversation.messages.filter(_.role == MessageRole.User).map(_.content).mkString Right(AssistantMessage(prompt.linesIterator.find(_.startsWith("[1] ")).fold("I do not know.")(_.drop(4)))) }) def demo(client: LLMClient): Result[String] = for { folder <- writeHandbook() result <- answer(client, folder, "How many days of paid vacation do employees get?") } yield s"${result.answer}\n(sources: ${result.contexts.flatMap(_.metadata.get("source")).distinct.mkString(", ")})" } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.FolderQaRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.FolderQaRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Two things change. The chat model is your provider's client (`--live` uses it). The embedding model is a real one: drop the `given ProviderRegistry` line, name the provider in `withEmbeddings`, and resolve its config from `llm4s.embeddings`: (not compile-checked) ```scala val config = RAG .builder() .withEmbeddings("openai", "text-embedding-3-small") // needs llm4s-openai and OPENAI_API_KEY .withLLM(client) RAG.build(config, _ => Llm4sConfig.embeddings().map(_._2)) ``` See [Embeddings configuration](../../getting-started/configuration#embeddings-configuration) and the [vector store guide](../../guide/vector-store) for a persistent store (SQLite, pgvector) instead of memory. ## Pitfalls - The stand-in embeddings match words, not meaning: "time off" does not find "vacation". A real embedding model does. - The documents and the question must be embedded with the same model. Changing the model means re-ingesting. - An in-memory pipeline is rebuilt on every run. For a folder that changes, use a persistent store and `sync`, which re-ingests only the files that changed. - `topK` passages go into the prompt: too few and the answer is missing, too many and the prompt is long and noisy. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/tool-calling # An agent that calls two tools Give an agent an exchange-rate tool of its own and the built-in calculator, and let the model chain them. ## The problem The model cannot know today's exchange rate, and is unreliable at arithmetic. Give it tools: it asks for the rate, the agent runs the tool and shows the model the result, then the model asks the calculator to multiply, and answers from that. `ToolBuilder` defines a tool from a name, a description, a parameter schema and a handler that returns `Either[String, A]`; the calculator comes from `llm4s-agent-tools`. The agent loops until the model answers without asking for a tool. ## The program The whole program, [`ToolCallingRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/ToolCallingRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.agent.{ Agent, AgentResult } import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, MessageRole, ToolCall } import org.llm4s.samples.util.AgentResults import org.llm4s.toolapi.builtin.core.CalculatorTool import org.llm4s.toolapi.{ Schema, ToolBuilder, ToolFunction, ToolRegistry } import org.llm4s.types.Result import upickle.default.ReadWriter final case class ExchangeRate(from: String, to: String, rate: Double) derives ReadWriter /** Recipe: an agent that calls two tools, one of its own and the built-in calculator, to answer one question. */ object ToolCallingRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "tool-calling", title = "An agent that calls two tools", summary = "Give an agent an exchange-rate tool and the calculator, and let the model chain them.", mainClass = "org.llm4s.samples.cookbook.ToolCallingRecipe" ) val question: String = "How much is 120 US dollars in euros?" val rates: Map[(String, String), Double] = Map(("USD", "EUR") -> 0.92, ("EUR", "USD") -> 1.09) val exchangeRate: Result[ToolFunction[Map[String, Any], ExchangeRate]] = ToolBuilder[Map[String, Any], ExchangeRate]( "exchange_rate", "Today's exchange rate from one currency to another", Schema .`object`[Map[String, Any]]("A currency pair") .withRequiredField("from", Schema.string("ISO 4217 code of the currency to convert from, e.g. USD")) .withRequiredField("to", Schema.string("ISO 4217 code of the currency to convert to, e.g. EUR")) ).withHandler { params => for { from <- params.getString("from").map(_.toUpperCase) to <- params.getString("to").map(_.toUpperCase) rate <- rates.get((from, to)).toRight(s"no rate from $from to $to") } yield ExchangeRate(from, to, rate) }.buildSafe() def run(client: LLMClient, question: String): Result[AgentResult] = for { rateTool <- exchangeRate calculator <- CalculatorTool.toolSafe agent <- Agent.builder("currency-agent", client).withTools(new ToolRegistry(Seq(rateTool, calculator))).build() result <- agent.run(question) } yield result /** Asks for the rate, then for the calculator with that rate, then answers with the calculator's result. */ def script: ScriptedClient = new ScriptedClient((conversation, _) => { val outputs = conversation.messages.filter(_.role == MessageRole.Tool).map(m => ujson.read(m.content)) def call(name: String, args: ujson.Obj) = AssistantMessage(None, Seq(ToolCall(s"call-$name", name, args))) Right(outputs.size match { case 0 => call("exchange_rate", ujson.Obj("from" -> "USD", "to" -> "EUR")) case 1 => call("calculator", ujson.Obj("operation" -> "multiply", "a" -> 120, "b" -> outputs(0)("rate").num)) case _ => AssistantMessage(s"120 US dollars is ${outputs(1)("formatted").str} euros at today's rate.") }) }) def demo(client: LLMClient): Result[String] = run(client, question).flatMap(AgentResults.requireCompleted) } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.ToolCallingRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.ToolCallingRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Run with `--live`, or build the agent on your provider's client. Tool calling needs a model that supports it (GPT-4o, Claude, Gemini and most current models do; small local models often do not). Give the agent a step limit in production with `Agent.builder(...).withMaxSteps(n)` so that a model that keeps asking for tools stops. ## Pitfalls - The description and parameter descriptions are all the model knows about a tool. Say what it returns and in what units. - A handler's `Left` is shown to the model as the tool's error, not raised: the model can try again or explain. The spec checks this for a currency pair with no rate. - Tools run with your program's permissions. Validate arguments in the handler; never pass them to a shell or a query unchecked. - Each tool round trip is another model call, with the whole thread so far: a chain of tools costs more than one answer. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/guardrails # A guardrailed chatbot Reject a bad request before the model sees it, and an over-long answer before the user does. ## The problem A chatbot open to users needs limits on what goes in and what comes out. Input guardrails run before the model sees the text, output guardrails on the final answer, and a request that fails an input guardrail never reaches the model, so it costs nothing. Here the agent refuses requests over 200 characters or with a listed word, and answers over 300 characters. ## The program The whole program, [`GuardrailsRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/GuardrailsRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.agent.graph.middleware.GuardrailMiddleware import org.llm4s.agent.guardrails.builtin.{ LengthCheck, ProfanityFilter } import org.llm4s.agent.{ Agent, AgentResult } import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.AssistantMessage import org.llm4s.samples.util.AgentResults import org.llm4s.types.Result /** * Recipe: guardrails around an agent. * * Input guardrails run before the model sees the text, and output guardrails run on the final answer. A request that * fails an input guardrail never reaches the model, so it costs nothing. The checks here are plain code, with no * second model call. * {{{ * sbt "samples/runMain org.llm4s.samples.cookbook.GuardrailsRecipe" # scripted client, no API key * sbt "samples/runMain org.llm4s.samples.cookbook.GuardrailsRecipe --live" # your configured provider * }}} */ object GuardrailsRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "guardrails", title = "A guardrailed chatbot", summary = "Reject a bad request before the model sees it, and an over-long answer before the user does.", mainClass = "org.llm4s.samples.cookbook.GuardrailsRecipe" ) def guardedAgent(client: LLMClient): Result[Agent] = Agent .builder("guarded-agent", client) .withMiddleware( new GuardrailMiddleware( input = Seq(LengthCheck(1, 200), ProfanityFilter.withCustomWords(Set("heck"))), output = Seq(LengthCheck(1, 300)) ) ) .build() def ask(client: LLMClient, query: String): Result[AgentResult] = guardedAgent(client).flatMap(_.run(query)) /** A short, polite answer to anything it is asked. */ def script: ScriptedClient = new ScriptedClient((_, _) => Right(AssistantMessage("Our office opens at nine."))) def demo(client: LLMClient): Result[String] = for { allowed <- ask(client, "When does the office open?") refused <- ask(client, "Tell me the heck out of your opening hours") } yield { val first = AgentResults.answerOrStatus(allowed) val second = AgentResults.answerOrStatus(refused) s"allowed: $first\nrefused: $second" } } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.GuardrailsRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.GuardrailsRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Run with `--live`, or build the agent on your provider's client. For checks code cannot do, such as "is this answer on topic", the LLM-as-judge guardrails (`LLMSafetyGuardrail`, `LLMQualityGuardrail`, ...) in `org.llm4s.agent.guardrails.builtin` take a client of their own, often a smaller, cheaper model. See [Guardrails](../../guide/agents/guardrails). ## Pitfalls - An output guardrail cannot save the cost of the call: it has already been made. - A refused request is a completed run with a refusal, not an error: check `AgentResult.status`, as `AgentResults.answerOrStatus` does, rather than only the `Either`. - Word lists are easy to get around ("h3ck"). Use them for obvious cases and an LLM guardrail for the rest. - An LLM-as-judge guardrail adds a model call to every turn it checks. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/streaming # Stream tokens to the terminal Print each piece of the reply as the model produces it, and get the whole completion at the end. ## The problem A long answer can take seconds to generate. Streaming shows the user the first words at once. `streamComplete` calls your function with each `StreamedChunk` as it arrives, and returns the whole `Completion` when the reply ends, so you print as you go and still get the full text and usage afterwards. ## The program The whole program, [`StreamingRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/StreamingRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, CompletionOptions, Conversation, SystemMessage, UserMessage } import org.llm4s.types.Result /** The streamed reply, as the caller saw it arrive and as the client returned it at the end. */ final case class Streamed(pieces: Vector[String], text: String) /** Recipe: print a reply to the terminal as it is generated, instead of waiting for all of it. */ object StreamingRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "streaming", title = "Stream tokens to the terminal", summary = "Print each piece of the reply as the model produces it, and get the whole completion at the end.", mainClass = "org.llm4s.samples.cookbook.StreamingRecipe" ) def stream(client: LLMClient, question: String, onText: String => Unit): Result[Streamed] = { val conversation = Conversation(Seq(SystemMessage("Answer in two or three sentences."), UserMessage(question))) val pieces = Vector.newBuilder[String] client .streamComplete( conversation, CompletionOptions().withMaxTokens(200), chunk => // A chunk can carry a tool call, a finish reason or reasoning instead of text: print only the text. chunk.content.filter(_.nonEmpty).foreach { text => pieces += text onText(text) } ) .map(completion => Streamed(pieces.result(), completion.content)) } /** Writes each piece as it arrives; `flush`, because `print` alone may hold the text back until a newline. */ def toTerminal(text: String): Unit = { print(text) Console.flush() } /** Streams a fixed reply a word at a time, as [[ScriptedClient]] streams every reply. */ def script: ScriptedClient = new ScriptedClient((_, _) => Right( AssistantMessage( "Streaming sends the reply in pieces while the model is still writing it. " + "The user sees the first words at once instead of waiting for the whole answer." ) ) ) def demo(client: LLMClient): Result[String] = stream(client, "Why stream a model's reply?", toTerminal).map { streamed => s"\n(${streamed.pieces.size} pieces, ${streamed.text.length} characters)" } } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.StreamingRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.StreamingRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Run with `--live`: every provider module streams. In an agent, build it with `Agent.builder(...).withStreaming()` and listen with `agent.stream(...)`: the text arrives as `AgentEvents.TextDelta` events, along with tool calls and steps; see [Streaming](../../guide/agents/streaming). ## Pitfalls - A chunk is not always text: it can carry a piece of a tool call, a finish reason or reasoning, with no content. Print only `chunk.content`, and skip empty pieces. - A chunk is a piece of a word as often as a whole one. Do not add spaces between chunks. - The callback runs on the client's thread, while the request is open. Keep it quick: hand slow work (a database write, a UI update across threads) to something else. - `print` may hold text back until a newline; flush after each piece. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/fallback # Fall back between providers Retry a provider with ReliableClient, and ask a second provider when the first still fails. ## The problem Providers have outages and rate limits. `ReliableClient` wraps a client with retries, a circuit breaker and a deadline, so a brief failure is retried and a long one fails fast. When the first provider is still failing, the recipe asks a second. The demo simulates the outage: the primary always fails with a 503, and the fallback is the scripted client, or with `--live` your configured provider. ## The program The whole program, [`FallbackRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/FallbackRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.error.ServiceError import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, Conversation, UserMessage } import org.llm4s.reliability.{ ReliabilityConfig, ReliableClient, RetryPolicy } import org.llm4s.types.Result import scala.concurrent.duration._ /** An answer and the provider that gave it. */ final case class Served(by: String, text: String) /** Two providers, each retried on its own, the second asked only when the first still fails. */ final class FallbackClients(primary: LLMClient, fallback: LLMClient, config: ReliabilityConfig) { // Build these once and keep them: each circuit breaker counts failures across calls. private val first = new ReliableClient(primary, "primary", config) private val second = new ReliableClient(fallback, "fallback", config) def ask(question: String): Result[Served] = { val conversation = Conversation(Seq(UserMessage(question))) first.complete(conversation).map(c => Served("primary", c.content)) match { case Left(_) => second.complete(conversation).map(c => Served("fallback", c.content)) case served => served } } } /** Recipe: fall back to a second provider when the first is down, after retrying it. */ object FallbackRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "fallback", title = "Fall back between providers", summary = "Retry a provider with ReliableClient, and ask a second one when the first still fails.", mainClass = "org.llm4s.samples.cookbook.FallbackRecipe" ) /** Two attempts per provider, 200 ms apart, and at most 30 seconds per call including the retries. */ val config: ReliabilityConfig = ReliabilityConfig( retryPolicy = RetryPolicy.fixedDelay(maxAttempts = 2, delay = 200.millis), deadline = Some(30.seconds) ) /** A provider that is down: every call fails with a 503, which `ReliableClient` treats as worth retrying. */ def outage: ScriptedClient = new ScriptedClient((_, _) => Left(ServiceError(503, "primary", "service unavailable"))) /** The provider that is up. */ def script: ScriptedClient = new ScriptedClient((_, _) => Right(AssistantMessage("Our office opens at nine on weekdays."))) /** The primary is a simulated outage, so the fallback, `client`, answers; with `--live` it is your provider. */ def demo(client: LLMClient): Result[String] = new FallbackClients(outage, client, config).ask("When does the office open?").map(s => s"[${s.by}] ${s.text}") } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.FallbackRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.FallbackRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Build the two clients from two named sections of your configuration, and drop the simulated outage: (not compile-checked) ```scala for { registry <- Llm4sConfig.modelRegistryService() given ModelRegistryService = registry primary <- Llm4sConfig.provider("openai-main").flatMap(config => LLMConnect.getClient(config)) fallback <- Llm4sConfig.provider("claude").flatMap(config => LLMConnect.getClient(config)) served <- new FallbackClients(primary, fallback, FallbackRecipe.config).ask(question) } yield served ``` Each section needs its provider module and key; see [Switching providers](../../getting-started/configuration#switching-providers) and the [reliability guide](https://github.com/llm4s/llm4s/blob/main/docs/reliability-guide.md). ## Pitfalls - Build each `ReliableClient` once and keep it: its circuit breaker counts failures across calls. One built per request never opens. - `ReliableClient` retries only errors a retry can fix (timeouts, 429, 5xx). A bad key or an invalid request fails at once; the recipe still tries the fallback then, which helps with a revoked key and not with a bad request. - The two providers answer differently, and may not support the same features (tools, schemas, context size). Test your prompts on both. - The retries and the deadline add up: two attempts per provider with a 30-second deadline can wait a minute before the fallback's error comes back. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/caching # Cache repeated calls Wrap a client in CachingLLMClient so that a question asked again is answered without a model call. ## The problem Many applications send the same prompt again and again: a help bot asked the same question by different users. `CachingLLMClient` wraps any client: it embeds each prompt, and when a prompt at least `similarityThreshold` similar was answered within the TTL with the same options, returns that answer without calling the model. The demo asks three questions, the third a repeat of the first, and the model is called twice. ## The program The whole program, [`CachingRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/CachingRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import cats.syntax.traverse._ import org.llm4s.config.Llm4sConfig import org.llm4s.llmconnect.caching.{ CacheConfig, CachingLLMClient } import org.llm4s.llmconnect.config.EmbeddingModelConfig import org.llm4s.llmconnect.model.{ AssistantMessage, Conversation, UserMessage } import org.llm4s.llmconnect.{ EmbeddingClient, LLMClient } import org.llm4s.model.ModelRegistryService import org.llm4s.trace.NoOpTracing import org.llm4s.types.Result import scala.concurrent.duration._ /** Recipe: answer a repeated question from a cache instead of calling the model again. */ object CachingRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "caching", title = "Cache repeated calls", summary = "Wrap a client in CachingLLMClient so that a question asked again is answered without a model call.", mainClass = "org.llm4s.samples.cookbook.CachingRecipe" ) /** `client`, behind a cache that answers a prompt at least 0.98 similar to one seen in the last hour. */ def cached(client: LLMClient): Result[LLMClient] = for { models <- Llm4sConfig.modelRegistryService() config <- CacheConfig.create(similarityThreshold = 0.98, ttl = 1.hour, maxSize = 500) } yield { given ModelRegistryService = models new CachingLLMClient( baseClient = client, // The stand-in embedding model; see "Use a real provider". embeddingClient = new EmbeddingClient(BagOfWordsEmbeddings.provider), embeddingModel = EmbeddingModelConfig(BagOfWordsEmbeddings.Model, BagOfWordsEmbeddings.Dimensions), config = config, tracing = new NoOpTracing ) } /** Asks each question in turn through one cache, so a repeat is answered from it. */ def askAll(client: LLMClient, questions: Seq[String]): Result[Seq[String]] = cached(client).flatMap { cache => questions.toList.traverse(q => cache.complete(Conversation(Seq(UserMessage(q)))).map(_.content)) } val questions: Seq[String] = Seq("When does the office open?", "Do you ship to Canada?", "When does the office open?") /** Numbers its answers, so the output shows which ones the model really gave. */ def script: ScriptedClient = new ScriptedClient((conversation, call) => Right(AssistantMessage(s"(model call $call) ${answerTo(conversation)}"))) private def answerTo(conversation: Conversation): String = if (conversation.messages.exists(_.content.contains("ship"))) "Yes, we ship to Canada." else "We open at nine." def demo(client: LLMClient): Result[String] = askAll(client, questions).map(answers => questions.zip(answers).map((q, a) => s"$q -> $a").mkString("\n")) } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.CachingRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.CachingRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider The chat client is your provider's (`--live` uses it). Replace the stand-in embeddings with a real embedding client, built from `llm4s.embeddings` (see [Embeddings configuration](../../getting-started/configuration#embeddings-configuration)), with a `ModelRegistryService` and `given ProviderRegistry = ProviderRegistry.default` in scope: (not compile-checked) ```scala embeddingClient <- Llm4sConfig.embeddings().flatMap((provider, config) => EmbeddingClient.from(provider, config)) ``` and give `embeddingModel` that model's name and dimensions. See [Caching](../../guide/caching) for the trade-offs. ## Pitfalls - The cache is approximate: two prompts the embedding model finds close get the same answer. Keep the threshold high (0.95 or more) and test it on prompts that must not share an answer ("open" and "close"). - Every call, hit or miss, embeds the prompt: the cache pays off only when a model call costs more than an embedding call and the hit rate is high. - Do not cache answers that depend on the time, the user or fresh data. - The cache is in memory, in the object you create: build it once and share it. `streamComplete` bypasses it. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/judge # Evaluate an answer with a judge Ask a model a question, then have a judge model grade the answer against a reference, from 1 to 5. ## The problem You changed a prompt or a model and want to know whether answers got better. People grading every answer does not scale; a second model can, given a rubric and a reference answer. The judge returns a `Verdict` (a score and a reason) through `completeStructured`, at temperature 0 so that the same answer gets the same grade. ## The program The whole program, [`JudgeRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/JudgeRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model._ import org.llm4s.toolapi.Schema import org.llm4s.types.Result import upickle.default.ReadWriter /** A judge's grade for one answer. */ final case class Verdict(score: Int, reason: String) derives ReadWriter { def passed: Boolean = score >= 4 } /** Recipe: grade an answer against a reference answer with a second model call (LLM-as-judge). */ object JudgeRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "judge", title = "Evaluate an answer with a judge", summary = "Ask a model a question, then have a judge model grade the answer against a reference, 1 to 5.", mainClass = "org.llm4s.samples.cookbook.JudgeRecipe" ) val Rubric: String = """You grade answers. Compare the answer with the reference answer, not with what you know. |5: says everything the reference says, and nothing that contradicts it. 4: a minor omission. |3: partly right. 2: mostly wrong. 1: wrong, or does not answer the question.""".stripMargin val verdictSchema = Schema .`object`[Verdict]("A grade for one answer") .withRequiredField("score", Schema.integer("The grade from the rubric, 1 to 5").withRange(Some(1), Some(5))) .withRequiredField("reason", Schema.string("One sentence explaining the grade")) def judge(judgeModel: LLMClient, question: String, reference: String, answer: String): Result[Verdict] = { val toGrade = s"Question: $question\nReference answer: $reference\nAnswer to grade: $answer" judgeModel.completeStructured[Verdict]( Conversation(Seq(SystemMessage(Rubric), UserMessage(toGrade))), verdictSchema, CompletionOptions().withTemperature(0.0) // the same answer should get the same grade ) } /** Answers `question` with `model` and grades the answer with `judgeModel`. */ def evaluate( model: LLMClient, judgeModel: LLMClient, question: String, reference: String ): Result[(String, Verdict)] = for { answer <- model.complete(Conversation(Seq(UserMessage(question)))).map(_.content) verdict <- judge(judgeModel, question, reference, answer) } yield (answer, verdict) /** Answers the question as the model; grades as the judge when it is given the rubric. */ def script: ScriptedClient = new ScriptedClient((conversation, _) => { val judging = conversation.messages.exists(m => m.role == MessageRole.System && m.content == Rubric) Right( AssistantMessage( if (judging) """{"score": 4, "reason": "Gives the 25 days but leaves out the carry-over rule."}""" else "Employees get 25 days of paid vacation a year." ) ) }) def demo(client: LLMClient): Result[String] = evaluate( client, client, "How much paid vacation do employees get?", "25 days a year; unused days carry over until 31 March." ).map((answer, v) => s"answer: $answer\nscore: ${v.score} (${if (v.passed) "pass" else "fail"}) - ${v.reason}") } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.JudgeRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.JudgeRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Run with `--live`, which uses one provider as both model and judge. In an evaluation, make the judge a separate, strong model, from a second named section (`Llm4sConfig.provider("judge")`), and run `evaluate` over a set of questions with reference answers. For RAG pipelines, the RAGAS metrics in `llm4s-rag` (faithfulness, answer relevancy, context precision) are judges of this kind; see [RAG evaluation](../../guide/rag-evaluation). ## Pitfalls - A judge is a model and can be wrong. Check a sample of its grades by hand before trusting the averages. - Judges tend to favour longer answers, and answers in their own style; a judge from the same model as the answerer is lenient with it. - Compare against a reference, not "what you know": without one, the judge grades its own beliefs. - Keep the rubric and the judge model fixed while you compare. Scores from different judges do not compare. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/document-qa # Answer questions with keyword search Chunk some text, put it in an in-memory keyword index (BM25), retrieve the best passage and answer from it. ## The problem Sometimes you want retrieval with no embedding model at all: a small set of documents, exact terms (product names, error codes), or nowhere to send text to be embedded. A keyword index (SQLite FTS5, BM25 ranking) finds the passage that shares the most words with the question; only that passage goes into the prompt. ## The program The whole program, [`DocumentQaRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/DocumentQaRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.chunking.{ ChunkerFactory, ChunkingConfig } import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, Conversation, MessageRole, SystemMessage, UserMessage } import org.llm4s.types.Result import org.llm4s.vectorstore.{ KeywordDocument, SQLiteKeywordIndex } /** An answer and the documents it was drawn from. */ final case class DocumentAnswer(text: String, sources: Seq[String]) /** * Recipe: answer a question from your own documents. * * The documents are split into chunks and put in an in-memory keyword index (BM25), the best chunk for the question * is retrieved, and only that chunk goes into the prompt. No embedding model is involved, so it runs anywhere. * * A keyword index matches words, not meaning: a question is turned into an OR of its content words, because a * question as written must contain every one of its words to match. For meaning-based search, see the vector stores * in `llm4s-rag`. * {{{ * sbt "samples/runMain org.llm4s.samples.cookbook.DocumentQaRecipe" # scripted client, no API key * sbt "samples/runMain org.llm4s.samples.cookbook.DocumentQaRecipe --live" # your configured provider * }}} */ object DocumentQaRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "document-qa", title = "Answer questions with keyword search", summary = "Chunk some text, index it, retrieve the best passage for a question and answer from it.", mainClass = "org.llm4s.samples.cookbook.DocumentQaRecipe" ) val handbook: Map[String, String] = Map( "vacation" -> "Employees receive 25 days of paid vacation per year. Unused days carry over until 31 March.", "expenses" -> "Expenses above 50 euros need a receipt. Claims must be filed within 30 days.", "remote" -> "Remote work is allowed up to three days a week with manager approval." ) val question: String = "How many vacation days do employees get?" private val stopWords = Set("what", "when", "where", "which", "does", "many", "much", "have", "from", "with") /** The question as an OR of its content words, quoted so that FTS5 reads them as plain words. */ private[cookbook] def keywordQuery(question: String): String = question.toLowerCase .split("[^a-z0-9]+") .filter(word => word.length > 3 && !stopWords.contains(word)) .distinct .map(word => "\"" + word + "\"") .mkString(" OR ") def answer(client: LLMClient, documents: Map[String, String], question: String): Result[DocumentAnswer] = SQLiteKeywordIndex.inMemory().flatMap { index => val chunking = ChunkingConfig(targetSize = 200, maxSize = 300, overlap = 0, minChunkSize = 0) val chunks = for { (source, text) <- documents.toSeq chunk <- ChunkerFactory.simple().chunk(text, chunking) } yield KeywordDocument(s"$source-${chunk.index}", chunk.content, Map("source" -> source)) // A question of only stop words and short words has no content words. FTS5 rejects an empty // MATCH as a syntax error, so skip the search and let the no-document answer stand. val query = keywordQuery(question) val outcome = for { _ <- index.indexBatch(chunks) hits <- if (query.isEmpty) Right(Seq.empty) else index.search(query, topK = 1) reply <- if (hits.isEmpty) Right(None) else { val context = hits.map(_.content).mkString("\n") val prompt = Conversation( Seq(SystemMessage(s"Answer only from this context.\n\n$context"), UserMessage(question)) ) client.complete(prompt).map(completion => Some(completion.content)) } } yield DocumentAnswer( reply.getOrElse("I could not find anything about that in the documents."), hits.flatMap(_.metadata.get("source")).distinct ) index.close() outcome } /** Answers with the first sentence of the context it was given. */ def script: ScriptedClient = new ScriptedClient((conversation, _) => { val system = conversation.messages.find(_.role == MessageRole.System).map(_.content).getOrElse("") val passage = system.split("\n\n").lastOption.getOrElse("") Right(AssistantMessage(passage.takeWhile(_ != '.') + ".")) }) def demo(client: LLMClient): Result[String] = answer(client, handbook, question).map(a => s"${a.text}\n(source: ${a.sources.mkString(", ")})") } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.DocumentQaRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.DocumentQaRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Pass your provider's client to `answer`, or run with `--live`. Nothing else changes: the index needs no provider. For documents on disk, see the [folder recipe](folder-qa), which reads files and adds vector search. ## Pitfalls - A keyword index matches words, not meaning. A question as written must contain every one of its words to match, so the recipe turns it into an OR of its content words. - A question of only stop words has no content words; FTS5 rejects an empty query, so the recipe answers "not found" without searching. - When nothing matches, say so instead of asking the model: it would answer from what it knows. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/memory # Remember facts between turns Record what a user says, and put the relevant facts in the system prompt when the next question arrives. ## The problem A model forgets everything between calls. To remember what a user told you, record it in a memory manager and, when the next question arrives, retrieve the facts that relate to it and put them in the system prompt. ## The program The whole program, [`MemoryRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/MemoryRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.agent.memory.{ MemoryManager, SimpleMemoryManager } import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, Conversation, MessageRole, SystemMessage, UserMessage } import org.llm4s.types.Result /** What the model said, and the remembered context it was shown. */ final case class Remembered(reply: String, context: String) /** * Recipe: remember what the user told you, and use it on a later turn. * * Facts are recorded in a memory manager, and when the next question arrives the relevant ones are retrieved and put * in the system prompt. The in-memory store matches whole words, ignoring case and punctuation, so the question has to * share a word with the fact: "Which language do I prefer, Scala or Java?" finds "Prefers Scala over Java", but * "What do I like?" does not, and neither would "What do I prefer?" ("prefer" is not "Prefers"). * {{{ * sbt "samples/runMain org.llm4s.samples.cookbook.MemoryRecipe" # scripted client, no API key * sbt "samples/runMain org.llm4s.samples.cookbook.MemoryRecipe --live" # your configured provider * }}} */ object MemoryRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "memory", title = "Remember facts between turns", summary = "Record what a user says, then retrieve it into the prompt when it matters.", mainClass = "org.llm4s.samples.cookbook.MemoryRecipe" ) val facts: Seq[String] = Seq("Prefers Scala over Java", "Works in the Berlin office") val question: String = "Which language do I prefer, Scala or Java?" def recall(client: LLMClient, facts: Seq[String], question: String): Result[Remembered] = for { manager <- facts.foldLeft[Result[MemoryManager]](Right(SimpleMemoryManager.empty)) { (manager, fact) => manager.flatMap(_.recordUserFact(fact, Some("user-1"), Some(0.9))) } context <- manager.getRelevantContext(question) reply <- client.complete( Conversation(Seq(SystemMessage(s"What you know about the user:\n$context"), UserMessage(question))) ) } yield Remembered(reply.content, context) /** Answers from the system prompt when it contains the fact, and admits it does not know otherwise. */ def script: ScriptedClient = new ScriptedClient((conversation, _) => { val system = conversation.messages.find(_.role == MessageRole.System).map(_.content).getOrElse("") Right(AssistantMessage(if (system.contains("Prefers Scala")) "You prefer Scala." else "I do not know yet.")) }) def demo(client: LLMClient): Result[String] = recall(client, facts, question).map(r => s"${r.reply}\n(context shown to the model: ${r.context.replace('\n', ' ')})" ) } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.MemoryRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.MemoryRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Pass your provider's client to `recall`, or run with `--live`. For memory that survives a restart, use the SQLite or Postgres stores of `llm4s-memory` and `llm4s-memory-postgres`; see [Memory](../../guide/agents/memory). ## Pitfalls - The in-memory store matches whole words, ignoring case and punctuation, so the question has to share a word with the fact: "Which language do I prefer, Scala or Java?" finds "Prefers Scala over Java", but "What do I like?" finds nothing, and neither does "What do I prefer?", because there is no stemming and "prefer" is not "Prefers" ([#1594](https://github.com/llm4s/llm4s/issues/1594)). For retrieval by meaning, use a store with embeddings. - Record facts with a user id and retrieve by it, or one user's facts reach another's prompt. [Back to the cookbook](../cookbook) --- Source: https://llm4s.org/examples/cookbook/multi-agent-graph # Several agents in one graph Ask two specialist agents in parallel, then let an editor agent combine their views once both have answered. ## The problem Some questions are better answered by several agents with different instructions, and an editor who weighs their views. The graph runtime runs both specialists in one superstep, applies their updates in task order whichever finishes first, and a static join holds the editor until both have committed. This replaces `PlanRunner` and `TypedAgent`, removed in [#1330](https://github.com/llm4s/llm4s/issues/1330). ## The program The whole program, [`MultiAgentGraphRecipe.scala`](https://github.com/llm4s/llm4s/blob/main/modules/samples/src/main/scala/org/llm4s/samples/cookbook/MultiAgentGraphRecipe.scala). `script` is the stand-in for a model that answers without a network or a key; `demo` runs the recipe and returns what to print. (not compile-checked) ```scala package org.llm4s.samples.cookbook import org.llm4s.agent.Agent import org.llm4s.agent.graph._ import org.llm4s.error.ExecutionError import org.llm4s.llmconnect.LLMClient import org.llm4s.llmconnect.model.{ AssistantMessage, MessageRole, SystemMessage } import org.llm4s.samples.util.AgentResults import org.llm4s.types.Result import upickle.default.ReadWriter import java.util.concurrent.{ ConcurrentLinkedQueue, CountDownLatch, TimeUnit } import scala.jdk.CollectionConverters._ /** One specialist's view of the question. */ final case class View(specialist: String, text: String) derives ReadWriter /** The editor's answer and the views it weighed, in task order. */ final case class Review(answer: String, views: Vector[View]) /** * Recipe: several agents in one graph. * * Two specialist agents answer the same question in parallel, and an editor agent combines their views once both * have answered. The graph runtime runs the specialists in one superstep, applies their updates in task order * whichever finishes first, and holds the editor behind a static join until both have committed. Cancelling the run * cancels the agents it is waiting on. Run it with * {{{ * sbt "samples/runMain org.llm4s.samples.cookbook.MultiAgentGraphRecipe" # scripted client, no API key * sbt "samples/runMain org.llm4s.samples.cookbook.MultiAgentGraphRecipe --live" # your configured provider * }}} */ object MultiAgentGraphRecipe extends RecipeApp { val info: RecipeInfo = RecipeInfo( id = "multi-agent-graph", title = "Several agents in one graph", summary = "Ask two specialist agents in parallel, then let an editor agent combine their views once both have answered.", mainClass = "org.llm4s.samples.cookbook.MultiAgentGraphRecipe" ) val question: String = "Should our team adopt Scala 3 for its next service?" val OptimistPrompt: String = "You are an optimist. Give the strongest case for the idea, in two sentences." val SkepticPrompt: String = "You are a skeptic. Give the strongest case against the idea, in two sentences." val EditorPrompt: String = "You are an editor. Weigh the views you are given and recommend one course of action." /** The thread the recipe runs on. */ val ReviewThread: ThreadId = ThreadId("review-1") private val questionKey = StateKey.replace[String]("question", "") private val views = StateKey.appending[View]("views") private val answer = StateKey.replace[String]("answer", "") /** * Runs `agent` on `query` on a thread of its own, then forgets that thread: the graph's thread is the record. A turn * that does not complete fails the node; a failed or cancelled one-shot turn is forgotten by `run` itself. */ private def ask(agent: Agent, query: String): Result[String] = agent.run(query).flatMap(turn => agent.forget(turn.threadId).flatMap(_ => AgentResults.requireCompleted(turn))) def graph(client: LLMClient): Result[CompiledGraph[String, Review]] = for { optimist <- Agent.builder("optimist", client).withSystemPrompt(OptimistPrompt).build() skeptic <- Agent.builder("skeptic", client).withSystemPrompt(SkepticPrompt).build() editor <- Agent.builder("editor", client).withSystemPrompt(EditorPrompt).build() compiled <- { val b = GraphBuilder("multi-agent-review", "v1") // each specialist reads the question and appends its view; both run in the same superstep def specialist(name: String, agent: Agent): NodeRef[Unit] = b.node[Unit](name, writes = Set(views)) { (_, state, _) => NodeResult.fromResult(for { q <- state.get(questionKey) text <- ask(agent, q) } yield Command.empty.update(views, View(name, text))) } val optimistNode = specialist("optimist", optimist) val skepticNode = specialist("skeptic", skeptic) val editorNode = b.node[Unit]("editor", writes = Set(answer)) { (_, state, _) => NodeResult.fromResult(for { q <- state.get(questionKey) vs <- state.get(views) text <- ask(editor, (s"Question: $q" +: vs.map(v => s"${v.specialist}: ${v.text}")).mkString("\n")) } yield Command.empty.update(answer, text)) } val brief = b.node[String]("brief", writes = Set(questionKey)) { (q, _, _) => NodeResult.Continue(Command.empty.update(questionKey, q).goto(optimistNode).goto(skepticNode)) } // the editor runs once both specialists have committed b.staticJoin("views", Set(optimistNode, skepticNode), editorNode): Unit b.compile(brief)(state => state.get(answer).flatMap(a => state.get(views).map(Review(a, _)))) } } yield compiled def start(runtime: GraphRuntime, client: LLMClient, question: String): Result[RunHandle[Review]] = graph(client).flatMap(g => runtime.start(ReviewThread, g, question)) def run(client: LLMClient, question: String): Result[Review] = start(GraphRuntime.inMemory(), client, question).flatMap(_.await()).flatMap(completed) /** * [[run]], also returning the run's durable events as `Kind` or `Kind@node`, in order. The events are delivered on * the subscription's own thread, so this waits for the run's last event before returning. */ def runTraced(client: LLMClient, question: String): Result[(Review, Vector[String])] = { val runtime = GraphRuntime.inMemory() val events = new ConcurrentLinkedQueue[String]() val ended = new CountDownLatch(1) for { subscription <- runtime.subscribe(ReviewThread) { case StreamEvent.Durable(record) => events.add(record.event.productPrefix + record.nodeId.fold("")(n => s"@$n")): Unit record.event match { case RunEvent.RunCompleted | RunEvent.RunCancelled(_, _) | RunEvent.RunTimedOut(_, _) | _: RunEvent.RunFailed | _: RunEvent.RunSuspended => ended.countDown() case _ => () } case _ => () } ran <- start(runtime, client, question).flatMap(_.await()) _ = ended.await(5, TimeUnit.SECONDS): Unit _ = subscription.cancel() review <- completed(ran) } yield (review, events.asScala.toVector) } private def completed(result: RunResult[Review]): Result[Review] = result match { case RunResult.Completed(_, review, _) => Right(review) case RunResult.Failed(_, error) => Left(error) case _: RunResult.Suspended => Left(ExecutionError("the review suspended", "graph.run")) } /** Each agent answers from its system prompt; the editor says how many views it was given. */ def script: ScriptedClient = new ScriptedClient((conversation, _) => { val prompt = conversation.messages.collectFirst { case s: SystemMessage => s.content }.getOrElse("") val asked = conversation.messages.filter(_.role == MessageRole.User).lastOption.map(_.content).getOrElse("") Right(prompt match { case OptimistPrompt => AssistantMessage("Scala 3 gives the team safer, clearer code from day one.") case SkepticPrompt => AssistantMessage("Hiring and library support for Scala 3 still lag behind.") case _ => AssistantMessage(s"Adopt it for one service first. I weighed ${asked.linesIterator.size - 1} views.") }) }) def demo(client: LLMClient): Result[String] = runTraced(client, question).map { case (review, events) => ((Vector("Events:") ++ events.map(" " + _) ++ review.views.map(v => s"${v.specialist}: ${v.text}")) :+ s"Editor: ${review.answer}").mkString("\n") } } ``` ## Run it ```bash sbt "samples/runMain org.llm4s.samples.cookbook.MultiAgentGraphRecipe" # scripted client, no API key sbt "samples/runMain org.llm4s.samples.cookbook.MultiAgentGraphRecipe --live" # the provider your configuration names ``` The first command needs nothing but sbt. The second uses the section `llm4s.providers.provider` names; see [running the samples](../../getting-started/configuration#running-the-samples). CI runs every recipe against its scripted client, and checks that the program on this page is the source file, so what you read here compiles and works. ## Use a real provider Run with `--live`, or pass your provider's client to `graph`. The agents can use different clients: a cheaper model for the specialists and a stronger one for the editor. See [multi-agent orchestration](../../guide/patterns/multi-agent-orchestration). ## Pitfalls - A node that calls `agent.run` blocks its task until the agent's turn ends, so cancelling the graph run cancels that turn. A node that calls `agent.start` and returns without awaiting it leaves the turn running: cancel it yourself. - A specialist that fails fails the run before the editor is asked. - Parallel specialists mean parallel model calls: mind your provider's rate limit. [Back to the cookbook](../cookbook)