Part 1: a first call

The assistant’s first job is to answer a customer’s question. This part sends the question with a short system prompt, streams the answer as it is generated, and builds the client from configuration.

The code is Part1FirstCall.scala.

Ask a question

An LLMClient is the one type your code talks to, whichever provider is behind it. A conversation is a sequence of messages: the system message says who the assistant is, the user message is the customer’s question.

1
2
3
4
5
6
7
8
9
10
11
12
import org.llm4s.llmconnect.LLMClient
import org.llm4s.llmconnect.model.{ CompletionOptions, Conversation, SystemMessage, UserMessage }
import org.llm4s.types.Result

val instructions: String =
  "You are the support assistant of Acme Shop. Answer in at most three sentences."

def conversationFor(question: String): Conversation =
  Conversation(Seq(SystemMessage(instructions), UserMessage(question)))

def ask(client: LLMClient, question: String): Result[String] =
  client.complete(conversationFor(question), CompletionOptions()).map(_.content)

complete returns a Result[Completion], which is Either[LLMError, Completion]: a provider error, a timeout or a refused key is a Left you handle, never an exception. .map(_.content) keeps the answer’s text and passes an error through unchanged.

ask takes the client as a parameter instead of building one. That keeps it testable: Part1Spec hands it a ScriptedLLMClient that answers from a script, and checks both the answer and the messages that were sent.

Stream the answer

A customer waiting for an answer would rather see it appear. streamComplete calls onChunk with each piece as the provider sends it, and still returns the whole completion at the end.

1
2
3
4
def askStreaming(client: LLMClient, question: String)(onText: String => Unit): Result[String] =
  client
    .streamComplete(conversationFor(question), CompletionOptions(), chunk => chunk.content.foreach(onText))
    .map(_.content)

Build the client from configuration

Only the application’s entry point knows which provider it uses. Llm4sConfig.defaultProvider() reads the section that llm4s.providers.provider names in application.conf, and LLMConnect.getClient builds its client:

1
2
3
4
5
6
7
8
9
10
11
import org.llm4s.config.Llm4sConfig
import org.llm4s.llmconnect.LLMConnect
import org.llm4s.model.ModelRegistryService

def configuredClient(): Result[LLMClient] =
  for {
    providerConfig <- Llm4sConfig.defaultProvider()
    registry       <- Llm4sConfig.modelRegistryService()
    given ModelRegistryService = registry
    client <- LLMConnect.getClient(providerConfig)
  } yield client

With OPENAI_API_KEY set, this section is all the configuration the assistant needs (see Configuration for Anthropic, Gemini and Ollama):

1
2
3
4
5
6
7
8
9
llm4s {
  providers {
    provider = "openai-main"
    openai-main {
      provider = "openai"
      model    = "gpt-4o-mini"
    }
  }
}

Run the part with its scripted client, then against your provider:

1
2
sbt "samples/runMain org.llm4s.samples.tutorial.supportassistant.Part1FirstCall"
sbt "samples/runMain org.llm4s.samples.tutorial.supportassistant.Part1FirstCall --live"

Exercise 1: answer in the customer’s language

Acme Shop has customers in France. Write Part1Exercise.askIn(client, question, language) in Exercises.scala: like ask, but the system message is instructions followed by " Answer in <language>.". Then run

1
sbt "samples/testOnly *Part1Spec"

The case “Your exercise 1” checks the messages your code sends and the answer it returns. Stuck? The solution is Part1Solution in the solutions package.

Next: Part 2, typed answers.