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.
LLM4S is published for Scala 3 only: there is no _2.13 artifact, and a cross-build is not planned before 1.0
(#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).
Setup
1
2
3
4
5
6
7
8
9
// 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.13build 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_3ones. scala-librarymust be 2.13.16 or later at run time, the version LLM4S’s Scala 3 standard library is built on. The Spark guide covers runtimes that pin an older one.
Calling LLM4S
From Scala 2.13, pass LLM4S’s using parameters as an explicit second argument list:
1
2
3
4
5
6
7
8
9
10
11
12
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).
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
// 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:
1
2
// src/main/java/City.java
public record City(String name, String country, int population) {}
1
2
3
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 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), 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
Strings (JAgentResult.threadId()).
How this is checked
The probes live in
docs/design/scala-2.13-interop/probes,
and 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:
1
2
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.