Pick Your Stack

LLM4S’s core API returns a plain Result[A] (Either[LLMError, A]) on the calling thread. Three modules wrap it for an effect system: llm4s-effect (cats-effect and fs2), llm4s-zio (ZIO and ZIO Streams) and llm4s-pekko (Pekko Streams). They are thin wrappers over the same calls, and one conformance suite, run by every bridge in CI, holds them to the same behaviour: one mental model, four syntaxes.

This page shows one small program in each of the four: a streamed completion, then an agent run with one tool, with an error branch and a timeout. Every program returns the same string for the same script. A spec runs all four against a scripted client and checks that the code below is the code it runs, word for word (EffectSystemsGuideSpec in modules/samples).

For direct style on virtual threads with Ox, see Direct Style and Ox; llm4s-ox runs the same suite.

Shared setup

The conversation, the question and the tool are the same in every program:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import org.llm4s.agent.Agent
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model.{ CompletionOptions, Conversation, UserMessage }
import org.llm4s.toolapi.{ Schema, ToolBuilder, ToolFunction, ToolRegistry }
import org.llm4s.types.Result
import scala.concurrent.duration.FiniteDuration

val greeting: Conversation = Conversation(Seq(UserMessage("Say hello in five words.")))
val question: String       = "What should I pack for Oslo?"

val temperature: Result[ToolFunction[Map[String, Any], Double]] =
  ToolBuilder[Map[String, Any], Double](
    "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(_ => 21.0)).buildSafe()

Each program returns "<reply> | <answer>", "failed: <message>" when the provider fails, or "timed out".

Plain Result

No dependency beyond core and llm4s-agent. Streaming is a callback on the calling thread, and a for over Result stops at the first error. Core has no timeout combinator: the call runs on a virtual thread, and interrupting that thread cancels the provider call, which returns Left(CancelledError).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.concurrent.{ FutureTask, TimeUnit }
import scala.util.Try

/** `body` on a virtual thread; `None` once `timeout` passes, and the thread is interrupted, cancelling the call. */
def withinTime[A](timeout: FiniteDuration)(body: => A): Option[A] = {
  val call = new FutureTask[A](() => body)
  Thread.startVirtualThread(call)
  val result = Try(call.get(timeout.toNanos, TimeUnit.NANOSECONDS)).toOption
  if (result.isEmpty) call.cancel(true)
  result
}

def plainProgram(client: LLMClient, timeout: FiniteDuration): String = {
  def program: Result[String] = {
    val reply = new StringBuilder
    for {
      _      <- client.streamComplete(greeting, CompletionOptions(), _.content.foreach(reply.append))
      tool   <- temperature
      agent  <- Agent.builder("assistant", client).withTools(new ToolRegistry(Seq(tool))).build()
      result <- agent.run(question)
    } yield s"$reply | ${result.answer.getOrElse("")}"
  }
  withinTime(timeout)(program).fold("timed out")(_.fold(error => s"failed: ${error.message}", identity))
}

cats-effect

llm4s-effect. The chunks are an fs2 Stream, and an LLMError is raised as LLMException in IO’s error channel. timeoutTo cancels the fiber, which interrupts the provider call.

1
2
3
4
5
6
7
8
9
10
11
12
13
import cats.effect.IO
import org.llm4s.effect.cats.{ LLMClientIO, LLMException }

def catsProgram(client: LLMClient, timeout: FiniteDuration): IO[String] = {
  val llm = LLMClientIO[IO](client)
  val program = for {
    reply  <- llm.streamComplete(greeting).map(_.content.getOrElse("")).compile.string
    tool   <- IO.fromEither(temperature.left.map(new LLMException(_)))
    agent  <- llm.agent("assistant")(_.withTools(new ToolRegistry(Seq(tool))))
    result <- agent.run(question)
  } yield s"$reply | ${result.answer.getOrElse("")}"
  program.timeoutTo(timeout, IO.pure("timed out")).recover { case e: LLMException => s"failed: ${e.error.message}" }
}

ZIO

llm4s-zio. The chunks are a ZStream, and the error channel is the LLMError itself, so the error branch is a typed fold. timeout interrupts the fiber, which interrupts the provider call.

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.llm4s.zio.LLMClientZ
import zio.{ Duration, UIO, ZIO }

def zioProgram(client: LLMClient, timeout: FiniteDuration): UIO[String] = {
  val llm = LLMClientZ(client)
  val program = for {
    reply  <- llm.streamComplete(greeting).map(_.content.getOrElse("")).runFold("")(_ + _)
    tool   <- ZIO.fromEither(temperature)
    agent  <- llm.agent("assistant")(_.withTools(new ToolRegistry(Seq(tool))))
    result <- agent.run(question)
  } yield s"$reply | ${result.answer.getOrElse("")}"
  program.timeout(Duration.fromScala(timeout)).fold(e => s"failed: ${e.message}", _.getOrElse("timed out"))
}

Pekko

llm4s-pekko. The chunks and the agent’s events are Sources, and a failed stream carries the LLMError in an LLMException. A Future cannot be cancelled, so the agent turn runs as a stream (agent.stream) rather than as agent.run: completionTimeout then fails the stream, and cancelling it interrupts the provider call. llm.agent(...) returns a plain Result, since building an agent does no I/O.

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
import java.util.concurrent.TimeoutException
import org.apache.pekko.actor.ActorSystem
import org.apache.pekko.stream.scaladsl.Sink
import org.llm4s.agent.graph.ThreadId
import org.llm4s.pekko.{ AgentStreamItem, LLMClientPekko, LLMException }
import scala.concurrent.Future

def pekkoProgram(client: LLMClient, timeout: FiniteDuration)(using system: ActorSystem): Future[String] = {
  import system.dispatcher
  val llm = LLMClientPekko(client)
  temperature.flatMap(tool => llm.agent("assistant")(_.withTools(new ToolRegistry(Seq(tool))))) match {
    case Left(error) => Future.successful(s"failed: ${error.message}")
    case Right(agent) =>
      val reply = llm.streamComplete(greeting).map(_.content.getOrElse("")).fold("")(_ + _)
      val answer = agent.stream(ThreadId("pick-your-stack"), question).collect {
        case AgentStreamItem.Done(result) => result.answer.getOrElse("")
      }
      reply
        .concatLazy(answer)
        .completionTimeout(timeout)
        .runWith(Sink.seq)
        .map(_.mkString(" | "))
        .recover {
          case _: TimeoutException => "timed out"
          case e: LLMException     => s"failed: ${e.error.message}"
        }
  }
}

What is guaranteed to be the same

Each bridge runs the shared conformance suite, StreamingBridgeContract (modules/llm4s-effect/src/test/contract/org/llm4s/effect/contract/StreamingBridgeContract.scala), against its own adapter, driven by a ScriptedLLMClient with no network. Its cancellation cases wait on latches, never on sleeps.

Behaviour Plain Result cats-effect, ZIO, Pekko
A provider error Left(error) The same LLMError instance, with its message, from complete, streamComplete, agent run and agent stream; never swallowed
An agent’s provider error Left(GraphError.NodeFailed(...)) from Agent.run The same error core’s Agent.run and Agent.stream return
Chunk order The provider’s The provider’s, delivered as they arrive
A failure mid-stream The callback has seen the chunks, then Left The chunks already received, then the error
Stopping early (take(2)) Not applicable (a callback) The provider stops producing chunks
Cancelling Interrupt the thread: Left(CancelledError) Interrupts the provider call and frees its thread
Agent events Agent.stream’s listener The same event sequence and the same result as core’s Agent.stream
Tools Where core’s Agent runs them Never on the runtime’s compute pool (io-compute, ZScheduler, Pekko’s default-dispatcher)

The honest differences

  Plain Result cats-effect ZIO Pekko
Error channel Left(LLMError) LLMException (field error) in F’s Throwable channel LLMError, typed LLMException failing the Future or the stream
Building an agent Agent.builder(...).build(): Result[Agent] llm.agent(id)(configure): F[AgentIO[F]] llm.agent(id)(configure): IO[LLMError, AgentZ] llm.agent(id)(configure): Result[AgentPekko]
Timeout No combinator in core: a virtual thread and an interrupt, RunBudgets.withTimeout for an agent run, or llm4s-ox’s timeoutOption timeout, timeoutTo timeout completionTimeout on a stream
Cancelling complete and run Interrupt the calling thread Cancel the fiber Interrupt the fiber Not possible: a Future. Use streamComplete or agent.stream
When a cancel returns At once (FutureTask.cancel) Once the call or the turn has ended Once the call or the turn has ended At once; the stream’s materialized Future[Done] completes once the call or turn has ended
Stream buffer None (a callback) 64 chunks, 256 agent events 64 chunks, 256 agent events bufferSize, 64 and 256 by default

Two classes share the name LLMException, org.llm4s.effect.cats.LLMException and org.llm4s.pekko.LLMException, so that neither module depends on the other; import the one of your stack. The contract found no divergence between the bridges; the differences above are each stack’s own idiom, by design.

Adding a bridge

A new effect bridge runs StreamingBridgeContract before it is merged: a spec in its test sources extends StreamingBridgeContract("<its name>") with an EffectBridge adapter that drives the bridge’s own effect or stream, and its build.sbt project adds the shared source set to its tests:

1
Test / unmanagedSourceDirectories += (llm4sEffect / Test / sourceDirectory).value / "contract"

CatsEffectBridgeContractSpec, ZioBridgeContractSpec, PekkoBridgeContractSpec and OxBridgeContractSpec are the four adapters to copy. A behaviour the new stack cannot offer (Pekko’s Future, which cannot be cancelled) is declared by the adapter (startComplete returns None) and written into the differences table above.