Coming from Python
You have written LLM code in Python with the OpenAI SDK, LangChain, LlamaIndex or Pydantic, and your team’s services are in Scala. This page puts the Python you know next to the same thing in Scala 3 with LLM4S, and at each step introduces the one Scala feature that step’s code needs, with a link to its page in the Scala 3 book. It is a reference map, not a course: read the section you need.
- The words side by side
- 1. A first call
- 2. Typed output
- 3. A tool
- 4. An agent loop
- 5. RAG
- 6. Async and streaming
- 7. Tests
- 8. Tooling
- Common mistakes coming from Python
- Next steps
Every Scala block below is compiled by a test in this repository (PythonDevelopersGuideSpec, in modules/samples),
which also checks that the code on this page is, word for word, the code it compiles. Every block that calls a model
is run against llm4s-testkit’s ScriptedLLMClient, a scripted model, with no API key and no network. Each block
carries its own imports, so you can copy one on its own.
The Python is shown for comparison only and is not run. Each Python block names the library and version it was checked against (on 2026-11-11) and links to the documentation it is taken from.
The words side by side
| Python | Scala 3 with LLM4S |
|---|---|
x = 1 |
val x = 1 (cannot be reassigned); var x = 1 if it must change |
None |
None, inside an Option[A]: Some(value) or None, never null |
| a raised exception | a returned Left(error): Result[A] is Either[LLMError, A] |
try / except |
match on the Result, or a for comprehension that stops at the first Left |
@dataclass, a Pydantic BaseModel |
case class |
f"Hello {name}" |
s"Hello $name" |
lambda x: x.content |
x => x.content, or _.content |
list, [f(x) for x in xs] |
Seq, xs.map(f) |
dict |
Map |
def f(x: int) -> str: |
def f(x: Int): String = |
a decorator (@tool) |
a value built with a builder (ToolBuilder) |
async def / await |
a callback, or an effect type (IO, ZIO) from an effect-system module |
pytest |
ScalaTest, MUnit or another test framework, run by sbt test |
unittest.mock |
llm4s-testkit’s ScriptedLLMClient |
pip, requirements.txt |
dependencies in build.sbt, or //> using dep lines in a Scala CLI file |
| a Jupyter notebook | the Scala CLI REPL or a worksheet |
1. A first call
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from openai import OpenAI
client = OpenAI()
completion = client.chat.completions.create(
model="gpt-5.5",
messages=[
{"role": "developer", "content": "Talk like a pirate."},
{
"role": "user",
"content": "How do I check if a Python object is an instance of a class?",
},
],
)
print(completion.choices[0].message.content)
(From openai-python 3.49.0, README.)
In Python a failed call raises an openai.APIError. In LLM4S a call that can fail returns a Result[A]: a
Right(value) when it worked, a Left(error) describing what went wrong when it did not. Nothing is thrown.
1
2
3
4
5
6
7
8
9
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model.Conversation
import org.llm4s.types.Result
def ask(client: LLMClient, question: String): Result[String] =
for {
conversation <- Conversation.fromPrompts("You answer in one sentence.", question)
completion <- client.complete(conversation)
} yield completion.content
New in Scala: the for comprehension
(Scala 3 book: for expressions).
Each <- takes the value out of a Right and goes on to the next line; the first Left stops the chain and becomes
the result. Read it as a sequence of steps, each of which may fail, without an if error: return after each one.
The client parameter is where Python’s OpenAI() would go; section 8 shows how to build one from configuration.
2. Typed output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from typing import List
from pydantic import BaseModel
from openai import OpenAI
class Step(BaseModel):
explanation: str
output: str
class MathResponse(BaseModel):
steps: List[Step]
final_answer: str
client = OpenAI()
completion = client.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "You are a helpful math tutor."},
{"role": "user", "content": "solve 8x + 31 = 2"},
],
response_format=MathResponse,
)
message = completion.choices[0].message
if message.parsed:
print(message.parsed.steps)
print("answer: ", message.parsed.final_answer)
else:
print(message.refusal)
(From openai-python 3.49.0, helpers.md, with Pydantic 2.14 models.)
The Pydantic model becomes a case class. llm4s-schema-derivation derives the JSON schema sent to the model from it
(derives SchemaOf), and upickle derives the parser (ReadWriter):
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model.Conversation
import org.llm4s.schema.*
import org.llm4s.types.Result
import upickle.default.ReadWriter
case class Capital(country: String, capital: String) derives SchemaOf, ReadWriter
def capitalOf(client: LLMClient, country: String): Result[Capital] =
for {
conversation <- Conversation.fromPrompts("Answer with the country and its capital.", s"Capital of $country?")
capital <- client.completeStructuredOf[Capital](conversation)
} yield capital
New in Scala: the case class
(Scala 3 book: case classes), an
immutable record with named, typed fields, equality and a readable toString, like a frozen dataclass. A reply that
does not fit the shape is a Left, not an exception. Derived schemas covers
nested classes, optional fields and enums.
3. A tool
1
2
3
4
5
6
7
8
9
10
11
from langchain.tools import tool
@tool
def search_database(query: str, limit: int = 10) -> str:
"""Search the customer database for records matching the query.
Args:
query: Search terms to look for
limit: Maximum number of results to return
"""
return f"Found {limit} results for '{query}'"
(From LangChain 1.4.9, Tools.)
LangChain reads the tool’s schema from the function’s signature and docstring. In LLM4S you declare it with the
Schema DSL and give ToolBuilder a handler that reads the arguments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.llm4s.toolapi.{ SafeParameterExtractor, Schema, ToolBuilder, ToolFunction }
import org.llm4s.types.Result
def searchDatabase: Result[ToolFunction[Map[String, Any], String]] = {
val schema = Schema
.`object`[Map[String, Any]]("A search of the customer database")
.withRequiredField("query", Schema.string("What to search for"))
.withOptionalField("limit", Schema.integer("The most records to return (default 10)"))
def handler(params: SafeParameterExtractor): Either[String, String] =
for {
query <- params.getString("query")
limit <- params.getOptionalInt("limit").left.map(_.getMessage)
} yield s"Found ${limit.getOrElse(10)} results for '$query'"
ToolBuilder[Map[String, Any], String](
"search_database",
"Search the customer database for records matching the query.",
schema
).withHandler(handler).buildSafe()
}
New in Scala: Option
(Scala 3 book: using Option to replace null).
Python’s limit: int = 10 is a value that may be missing. Scala says so in the type: getOptionalInt gives an
Option[Int], either Some(2) or None, and limit.getOrElse(10) supplies the default. A missing required
argument (query) is a Left with a message the model can act on. (_.getMessage is a short function, explained in
section 5.) Build your own tool covers the rest of the DSL, and Tool.of derives the schema from a
case class instead.
4. An agent loop
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
def get_user_info() -> str:
"""Look up information about the current user."""
return "No user profile on file."
agent = create_agent(
model="google_genai:gemini-3.6-flash",
tools=[get_user_info],
checkpointer=InMemorySaver(),
)
thread_config = {"configurable": {"thread_id": "1"}}
response = agent.invoke(
{"messages": [{"role": "user", "content": "Hi! My name is Bob."}]},
thread_config,
)["messages"][-1].content
(Abridged from LangChain 1.4.9 with LangGraph 1.2.20, Short-term memory.)
An LLM4S Agent runs the same loop: call the model, run the tools it asks for, feed back their results, until it
answers. agent.run starts a conversation (a thread) and continueConversation adds a turn to it, as invoking with
the same thread_id does:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import org.llm4s.agent.{ Agent, AgentResult, AgentStatus }
import org.llm4s.llmconnect.LLMClient
import org.llm4s.toolapi.ToolRegistry
import org.llm4s.types.Result
def describe(result: AgentResult): String =
result.status match {
case AgentStatus.Completed(answer) => answer
case AgentStatus.Blocked(_, reason) => s"Blocked: $reason"
case other => s"Stopped: $other"
}
def twoTurns(client: LLMClient, tools: ToolRegistry): Result[Seq[String]] =
for {
agent <- Agent.builder("support", client).withTools(tools).withSystemPrompt("You help support staff.").build()
first <- agent.run("Find customers named Ada")
second <- agent.continueConversation(first, "Only the first two, please")
} yield Seq(describe(first), describe(second))
Pass it new ToolRegistry(Seq(tool)), with the tool from section 3.
New in Scala: the match expression
(Scala 3 book: match expressions).
A run ends in one of a fixed set of statuses (completed, blocked by a guardrail, out of steps, waiting for a human),
and match picks the case and pulls out its fields, like Python’s match statement. Without the last case other, the
compiler would warn about the statuses no case covers. Agents covers guardrails, handoffs and graphs.
5. RAG
1
2
3
4
5
6
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
documents = SimpleDirectoryReader("YOUR_DATA_DIRECTORY").load_data()
index = VectorStoreIndex.from_documents(documents)
query_engine = index.as_query_engine()
query_engine.query("YOUR_QUESTION")
(Abridged from llama-index-core 0.14.25, README.)
llm4s-rag is the index and the query engine in one: it chunks and embeds what you ingest, retrieves the closest
chunks, and asks the model to answer from them.
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.rag.RAG
import org.llm4s.types.Result
def askTheDocs(rag: RAG, question: String): Result[String] =
for {
_ <- rag.ingestText("Refunds are paid within 14 days of a return.", "refunds")
_ <- rag.ingestText("Orders over 50 EUR ship for free.", "shipping")
answer <- rag.queryWithAnswer(question)
} yield answer.answer
def sources(rag: RAG, question: String): Result[Seq[String]] =
rag.query(question).map(results => results.map(_.content))
Building the RAG (RAG.builder(), with an embedding provider, a vector store and the chat client) is shown on
RAG in Scala; withDocuments(path) loads a directory, as SimpleDirectoryReader does.
New in Scala: anonymous functions
(Scala 3 book: anonymous functions).
results => results.map(_.content) is Python’s lambda results: [r.content for r in results]. _.content is the
short form of r => r.content, for a function that uses its argument once. map on a Result changes the value in a
Right and passes a Left through.
6. Async and streaming
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import asyncio
from openai import AsyncOpenAI
client = AsyncOpenAI()
async def main():
stream = await client.responses.create(
model="gpt-5.5",
input="Write a one-sentence bedtime story about a unicorn.",
stream=True,
)
async for event in stream:
print(event)
asyncio.run(main())
(From openai-python 3.49.0, README: Streaming responses.)
streamComplete hands each piece of the answer to a function you pass, as it arrives, and returns the whole
completion at the end:
1
2
3
4
5
6
7
8
9
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model.{ CompletionOptions, Conversation }
import org.llm4s.types.Result
def streamAnswer(client: LLMClient, question: String, onText: String => Unit): Result[String] =
for {
conversation <- Conversation.fromPrompts("You answer in one sentence.", question)
completion <- client.streamComplete(conversation, CompletionOptions(), chunk => chunk.content.foreach(onText))
} yield completion.content
Call it as streamAnswer(client, "What is Scala?", print).
New in Scala: function types
(Scala 3 book: higher-order functions). String => Unit
is the type of a function that takes a String and returns nothing useful (Unit, Python’s None return), so
onText is a callback parameter, typed.
Which effect system will my team use? The call above blocks its thread until the answer is complete, which is
fine in a script or a plain service. A Scala team that writes asynchronous code usually standardises on one library,
the way a Python team standardises on asyncio: cats-effect (IO, with fs2 streams),
ZIO, or Pekko Streams. LLM4S has a thin module for each; Pick your stack shows one
program in all of them, so you can follow whichever your team already uses.
7. Tests
1
2
3
4
5
6
7
8
9
from unittest.mock import MagicMock
def test_ask_returns_the_answer():
client = MagicMock()
client.chat.completions.create.return_value.choices[0].message.content = "Paris"
assert ask(client, "Capital of France?") == "Paris"
client.chat.completions.create.assert_called_once()
(Written with pytest 9.2.0 and Python’s
unittest.mock; ask wraps section 1’s call.)
A mock reproduces the SDK’s object graph by hand. llm4s-testkit’s ScriptedLLMClient is a real LLMClient that
answers from a script and records what was sent, so the test exercises your code, not a mock of the SDK. This
ScalaTest class tests ask from section 1:
1
2
3
4
5
6
7
8
9
10
11
12
13
import org.llm4s.testkit.{ Reply, ScriptedLLMClient }
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class AskSpec extends AnyFlatSpec with Matchers {
"ask" should "return the model's answer" in {
val client = ScriptedLLMClient.sequence(Reply.text("Paris"))
ask(client, "Capital of France?") shouldBe Right("Paris")
client.callCount shouldBe 1
client.calls.head.messages.last.content shouldBe "Capital of France?"
}
}
New in Scala: traits
(Scala 3 book: traits).
AnyFlatSpec with Matchers mixes two traits into the class: one supplies the "..." should "..." in test syntax,
the other shouldBe. A trait is like a Python mixin class. Testing with the testkit
scripts tool calls and failures, and shows MUnit, Weaver and ZIO Test.
8. Tooling
The Python setup you know:
1
2
3
4
5
python -m venv .venv
source .venv/bin/activate
pip install openai
export OPENAI_API_KEY=sk-...
python main.py
The closest Scala equivalent is Scala CLI: one .scala file whose first lines
declare its dependencies (//> using dep org.llm4s::llm4s-core:...), run with scala-cli run main.scala. No virtual
environment: dependencies are resolved per file and cached. The Scala CLI quick start
has a complete one-file program. For the notebook workflow, Try llm4s in the REPL covers
the Scala CLI REPL and Metals worksheets; Almond (Jupyter) notebooks are tracked in
#2296. A service uses sbt and a build.sbt instead
(installation).
Where Python reads OPENAI_API_KEY in OpenAI(), LLM4S reads a named provider section from application.conf
(llm4s-openai binds OPENAI_API_KEY for you):
1
2
3
4
5
6
7
8
9
10
llm4s {
providers {
provider = "openai-main"
openai-main {
provider = "openai"
model = "gpt-4o-mini"
}
}
}
and builds the client every section above takes as client:
1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.{ LLMClient, LLMConnect }
import org.llm4s.model.ModelRegistryService
import org.llm4s.types.Result
def connect(): Result[LLMClient] =
for {
providerConfig <- Llm4sConfig.defaultProvider()
registryService <- Llm4sConfig.modelRegistryService()
given ModelRegistryService = registryService
client <- LLMConnect.getClient(providerConfig)
} yield client
New in Scala: given instances
(Scala 3 book: context parameters). Some
methods take a parameter that is passed for you rather than written at each call. given ModelRegistryService =
registryService makes the model registry that value for the lines after it, and LLMConnect.getClient picks it up.
Configuration covers other providers and keys.
Common mistakes coming from Python
Reaching for
varand mutable collections. Python names can be rebound and lists changed in place; idiomatic Scala usesvaland immutableSeq/Map, and returns a new value instead of changing the old one (Scala 3 book: two types of variables). Avaris not wrong, but an agent or a stream that shares one across threads is a race.Using
nullfor “no value”. It compiles, because Scala runs on the JVM, but nothing in LLM4S returnsnull: a value that may be missing is anOption, and a call that may fail is aResult. Match on them, or usegetOrElse, instead of checking fornullor catching an exception.Blocking inside effect code.
client.completeandagent.runblock their thread until the model answers. In plain code that is fine. Inside cats-effectIOor ZIO, calling them directly is like calling a blocking function inside anasync def: it stalls the runtime’s threads. Use the effect module’s wrappers (LLMClientIO,LLMClientZand the others on Pick your stack), which run the call on a blocking pool.Expecting exceptions.
try/exceptaround an LLM4S call catches nothing useful: the failure is theLeftthe call returned. Handle it where you handle the value.
Next steps
- First example: a complete program, run with sbt.
- Replacing a Python sidecar: keep Python models or tools while the LLM logic moves to Scala.
- Comparing LLM4S: how LLM4S compares with other JVM libraries.