Tool calling

Tools (also called function calling) let the model request that your application execute a function and report its result back. Each tool is described to the model by a name, a description, and a JSON Schema for its parameters — see JSON Schemas: structured outputs & tools for all the ways to produce one.

Calling a tool with Tool.Function.withSchema[T]

The example below books a flight using a function tool. The flow:

  • The user sends a message asking to book a flight, together with a function tool definition — meaning there is a function on the client side which knows how to book a flight. The tool definition carries a JSON Schema describing the function’s arguments.

  • The assistant responds with a tool call: arguments matching that schema.

  • The application decodes the arguments, calls the actual function, and sends its result back to the assistant.

  • The assistant produces the final answer.

The key point is Tool.Function.withSchema[T]: the JSON Schema for the arguments is generated automatically from the case class T, which only needs a Tapir Schema. A related convenience is creating the Message.Tool reply from an object rather than a JSON string — it is serialized automatically given a circe Encoder.

Note that the arguments sent back by the assistant still need to be deserialized manually before calling the function.

//> using dep com.softwaremill.sttp.ai::openai:0.8.0

import sttp.ai.openai.OpenAISyncClient
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.ChatBody
import sttp.ai.openai.requests.completions.chat.ChatRequestBody.ChatCompletionModel.GPT4oMini
import sttp.ai.openai.requests.completions.chat.ToolCall.FunctionToolCall
import sttp.ai.openai.requests.completions.chat.message.Content.TextContent
import sttp.ai.openai.requests.completions.chat.message.Message.{Assistant, Tool, User}
import sttp.ai.openai.requests.completions.chat.message.Tool.Function
import sttp.tapir.generic.auto.*

case class Passenger(name: String, age: Int)

object Passenger:
  given io.circe.Decoder[Passenger] = io.circe.generic.semiauto.deriveDecoder[Passenger]

case class FlightDetails(passenger: Passenger, departureCity: String, destinationCity: String)

object FlightDetails:
  given io.circe.Decoder[FlightDetails] = io.circe.generic.semiauto.deriveDecoder[FlightDetails]

case class BookedFlight(confirmationNumber: String, status: String)

object BookedFlight:
  given io.circe.Encoder[BookedFlight] = io.circe.generic.semiauto.deriveEncoder[BookedFlight]

object Main:
  def main(args: Array[String]): Unit =
    val apiKey = System.getenv("OPENAI_KEY")
    val openAI = OpenAISyncClient(apiKey)

    val initialRequestMessage = Seq(User(content = TextContent("I want to book a flight from London to Tokyo for Jane Doe, age 34")))

    // Request created using Tool.Function.withSchema, all we need to do here is just define the type. The schema is automatically generated using a macro, available via the `sttp.tapir.generic.auto.*` import.
    val givenRequest = ChatBody(
      model = GPT4oMini,
      messages = initialRequestMessage,
      tools = Some(Seq(
        Function.withSchema[FlightDetails](
          name = "book_flight",
          description = Some("Books a flight for a passenger with full details")))
      )
    )

    val initialRequestResult = openAI.createChatCompletion(givenRequest)

    println(initialRequestResult.choices)
    /*
      List(
        Choices(
          Message(
            null,
            None,
            List(
              FunctionToolCall(
                Some(call_XZNvfldLQTa1f7aMInswpTMS),
                FunctionCall(
                  {
                    "passenger": {
                      "name": "Jane Doe",
                      "age": 34
                    },
                    "departureCity": "London",
                    "destinationCity": "Tokyo"
                  },
                  Some(book_flight)
                )
              )
            ),
            Assistant,
            None,
            None
          ),
          tool_calls,
          0,
          None
        )
      )
      */

    // Helper function that mimics external function definition
    def bookFlight(flightDetails: FlightDetails): BookedFlight =
      println(flightDetails)
      BookedFlight(confirmationNumber = "123456", status = "confirmed")

    // Tool calls list (in this example it is just single tool call, but there may be multiple), which is necessary to build message list for second request.
    val toolCalls = initialRequestResult.choices.head.message.toolCalls

    val functionToolCall = toolCalls.head match
      case functionToolCall: FunctionToolCall => functionToolCall

    // Function arguments are manually deserialized, 'bookFlight' function mimic external function definition.
    val bookedFlight = bookFlight(io.circe.parser.decode[FlightDetails](functionToolCall.function.arguments).toTry.get)

    val secondRequest = givenRequest.copy(
      messages = initialRequestMessage
        :+ Assistant(content = "", toolCalls = toolCalls)
        // Tool message created using object instead of String with Json representation of object.
        :+ Tool(toolCallId = functionToolCall.id.get, content = bookedFlight)
    )

    val finalResult = openAI.createChatCompletion(secondRequest)

    println(finalResult.choices)
    /*
      List(
        Choices(
          Message(
            "The flight from London to Tokyo for Jane Doe, age 34, has been successfully booked. The confirmation number is **123456** and the status is **confirmed**.",
            None,
            List(),
            Assistant,
            None,
            None
          ),
          stop,
          0,
          None
        )
      )
      */

Using the agent loop

For a full automatic tool-calling loop — the model calls a tool, your code runs it, the result is fed back, repeat until the model produces a final answer — use OpenAIAgent instead of driving createChatCompletion by hand: see the agent loop.