Repository: softwaremill/sttp-openai
Pull request #305 (2025-03-21 09:52:07)

Issue 268 - Support for tool calls


Patch:
diff --git a/README.md b/README.md
index 4c0814b..5f0bbec 100644
--- a/README.md
+++ b/README.md
@@ -539,6 +539,153 @@ val jsonSchema: ASchema = TapirSchemaToJsonSchema(
 )
 ```
 
+#### Generating JSON Schema from case class
+
+We can also generate JSON Schema directly from case class, without defining the schema manually.
+
+In the example below I define such use case. User tries to book a flight, using function tool. The flow looks as follows:
+- User sends a message with the request to book a flight and provides function tool, which means that there is a function on a client side which 'knows' how to book a flight. Within this call it is necessary to provide Json Schema to define function arguments.
+- Assistant sends a message with arguments created based on Json Schema provided in the first step.
+- User calls custom function with arguments sent by Assistant before.
+- User sends result from the function call to Assistant.
+- Assistant sends a final result to User.
+
+The key point here is introducing SchematizedFunctionTool class. With this class in place Json Schema can be automatically generated using TapirSchemaToJsonSchema functionality. All we need to do is to define case class with [Tapir Schema](https://tapir.softwaremill.com/en/latest/endpoint/schemas.html) defined for it.
+
+Another helpful feature is adding possibility to create ToolMessage object passing object instead of String, which will be automatically serialized to Json. All you have to do is just define SnakePickle.Writer for specific class.
+
+With all this in mind please remember that it is still required to deserialized arguments, which are sent back by Assistant to call our function.
+
+```scala mdoc:compile-only
+//> using dep com.softwaremill.sttp.openai::core:0.3.1
+
+import sttp.openai.OpenAISyncClient
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
+import sttp.openai.requests.completions.chat.ChatRequestBody.ChatCompletionModel.GPT4oMini
+import sttp.openai.requests.completions.chat.ToolCall.FunctionToolCall
+import sttp.openai.requests.completions.chat.message.Content.TextContent
+import sttp.openai.requests.completions.chat.message.Message.{AssistantMessage, ToolMessage, UserMessage}
+import sttp.openai.requests.completions.chat.message.Tool.SchematizedFunctionTool
+import sttp.tapir.generic.auto._
+
+case class Passenger(name: String, age: Int)
+
+object Passenger {
+  implicit val passengerR: SnakePickle.Reader[Passenger] = SnakePickle.macroR[Passenger]
+}
+
+case class FlightDetails(passenger: Passenger, departureCity: String, destinationCity: String)
+
+object FlightDetails {
+  implicit val flightDetailsR: SnakePickle.Reader[FlightDetails] = SnakePickle.macroR[FlightDetails]
+}
+
+case class BookedFlight(confirmationNumber: String, status: String)
+
+object BookedFlight {
+  implicit val bookedFlightW: SnakePickle.Writer[BookedFlight] = SnakePickle.macroW[BookedFlight]
+}
+
+object Main extends App {
+  val apiKey = System.getenv("OPENAI_KEY")
+  val openAI = OpenAISyncClient(apiKey)
+
+  val initialRequestMessage = Seq(UserMessage(content = TextContent("I want to book a flight from London to Tokyo for Jane Doe, age 34")))
+
+  // Request created using SchematizedFunctionTool, 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(
+      SchematizedFunctionTool[FlightDetails](
+        description = "Books a flight for a passenger with full details",
+        name = "book_flight"))
+    )
+  )
+
+  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
+      )
+    )
+    */
+
+  // 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(SnakePickle.read[FlightDetails](functionToolCall.function.arguments))
+
+  val secondRequest = givenRequest.copy(
+    messages = initialRequestMessage
+      :+ AssistantMessage(content = "", toolCalls = toolCalls)
+      // ToolMessage created using object instead of String with Json representation of object.
+      :+ ToolMessage(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
+      )
+    )
+    */
+
+  def bookFlight(flightDetails: FlightDetails): BookedFlight = {
+    println(flightDetails)
+    BookedFlight(confirmationNumber = "123456", status = "confirmed")
+  }
+
+}
+```
+
 ## Contributing
 
 If you have a question, or hit a problem, feel free to post on our community https://softwaremill.community/c/open-source/
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestBody.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestBody.scala
index d99ace0..78592b5 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestBody.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestBody.scala
@@ -1,14 +1,10 @@
 package sttp.openai.requests.completions.chat
 
-import io.circe.syntax._
-import io.circe.{DecodingFailure, Json, JsonNumber, JsonObject}
 import sttp.apispec.Schema
-import sttp.apispec.circe._
 import sttp.openai.json.SnakePickle
 import sttp.openai.requests.completions.Stop
 import sttp.openai.requests.completions.chat.message.{Message, Tool, ToolChoice}
 import ujson._
-import ujson.circe.CirceJson
 
 object ChatRequestBody {
 
@@ -19,82 +15,10 @@ object ChatRequestBody {
     case object JsonObject extends ResponseFormat
     case class JsonSchema(name: String, strict: Boolean, schema: Schema) extends ResponseFormat
     object JsonSchema {
-      case class ParseException(circeException: DecodingFailure) extends Exception("Failed to parse JSON schema", circeException)
-
       private case class InternalRepr(json_schema: JsonSchema)
 
       private object InternalRepr {
-        case class FolderState(
-            fields: List[(String, Json)],
-            addAdditionalProperties: Boolean,
-            requiredProperties: List[String]
-        )
-
-        /** OpenAI's JSON schema support imposes two requirements:
-          *
-          *   1. All fields must be `required`: https://platform.openai.com/docs/guides/structured-outputs/all-fields-must-be-required
-          *
-          * 2. `additionalProperties: false` must always be set in objects:
-          * https://platform.openai.com/docs/guides/structured-outputs/additionalproperties-false-must-always-be-set-in-objects
-          *
-          * We implement these by folding over the JSON structure. However, if a schema uses discriminated unions (indicated by a
-          * `discriminator` property), we skip forcing `additionalProperties: false` to preserve flexibility in selecting sub-schemas.
-          */
-        private val schemaFolder: Json.Folder[Json] = new Json.Folder[Json] {
-          lazy val onNull = Json.Null
-          def onBoolean(value: Boolean): Json = Json.fromBoolean(value)
-          def onNumber(value: JsonNumber): Json = Json.fromJsonNumber(value)
-          def onString(value: String): Json = Json.fromString(value)
-          def onArray(value: Vector[Json]): Json = Json.fromValues(value.map(_.foldWith(this)))
-          def onObject(value: JsonObject): Json = {
-            val state = value.toList.foldRight(FolderState(Nil, false, Nil)) { case ((k, v), acc) =>
-              if (k == "properties")
-                acc.copy(
-                  fields = (k, v.foldWith(this)) :: acc.fields,
-                  addAdditionalProperties = true,
-                  requiredProperties = v.asObject.fold(List.empty[String])(_.keys.toList)
-                )
-              else if (k == "type")
-                acc.copy(
-                  fields = (k, v.foldWith(this)) :: acc.fields,
-                  addAdditionalProperties = acc.addAdditionalProperties || v.asString.contains("object")
-                )
-              else
-                acc.copy(fields = (k, v.foldWith(this)) :: acc.fields)
-            }
-
-            // Detect if this object is part of a discriminated union by checking for a "discriminator" property.
-            val isDiscriminatedUnion = value.contains("discriminator")
-
-            val (addlPropsRemove, addlPropsAdd) =
-              if (state.addAdditionalProperties && !isDiscriminatedUnion)
-                (Set("additionalProperties"), List("additionalProperties" := false))
-              else
-                (Set(), Nil)
-
-            val (requiredRemove, requiredAdd) =
-              if (state.requiredProperties.nonEmpty)
-                (Set("required"), List("required" := state.requiredProperties))
-              else
-                (Set(), Nil)
-
-            val remove = addlPropsRemove ++ requiredRemove
-            val fields = addlPropsAdd ++ requiredAdd ++ state.fields.filterNot { case (k, _) => remove.contains(k) }
-
-            Json.fromFields(fields)
-          }
-        }
-
-        implicit private val schemaRW: SnakePickle.ReadWriter[Schema] = SnakePickle
-          .readwriter[Value]
-          .bimap(
-            s => CirceJson.transform(s.asJson.deepDropNullValues.foldWith(schemaFolder), upickle.default.reader[Value]),
-            v =>
-              upickle.default.transform(v).to(CirceJson).as[Schema] match {
-                case Left(e)  => throw new ParseException(e)
-                case Right(s) => s
-              }
-          )
+        implicit private val schemaRW: SnakePickle.ReadWriter[Schema] = SchemaSupport.schemaRW
 
         implicit private val jsonSchemaRW: SnakePickle.ReadWriter[JsonSchema] = SnakePickle
           .readwriter[Value]
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/SchemaSupport.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/SchemaSupport.scala
new file mode 100644
index 0000000..44d0199
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/SchemaSupport.scala
@@ -0,0 +1,87 @@
+package sttp.openai.requests.completions.chat
+
+import io.circe.syntax._
+import io.circe.{DecodingFailure, Json, JsonNumber, JsonObject}
+import sttp.apispec.Schema
+import sttp.apispec.circe._
+import sttp.openai.json.SnakePickle
+import ujson._
+import ujson.circe.CirceJson
+
+object SchemaSupport {
+
+  private case class ParseException(circeException: DecodingFailure) extends Exception("Failed to parse JSON schema", circeException)
+
+  val schemaRW: SnakePickle.ReadWriter[Schema] = SnakePickle
+    .readwriter[Value]
+    .bimap(
+      s => CirceJson.transform(s.asJson.deepDropNullValues.foldWith(schemaFolder), upickle.default.reader[Value]),
+      v =>
+        upickle.default.transform(v).to(CirceJson).as[Schema] match {
+          case Left(e)  => throw ParseException(e)
+          case Right(s) => s
+        }
+    )
+
+  private case class FolderState(
+      fields: List[(String, Json)],
+      addAdditionalProperties: Boolean,
+      requiredProperties: List[String]
+  )
+
+  /** OpenAI's JSON schema support imposes two requirements:
+    *
+    *   1. All fields must be `required`: https://platform.openai.com/docs/guides/structured-outputs/all-fields-must-be-required
+    *
+    * 2. `additionalProperties: false` must always be set in objects:
+    * https://platform.openai.com/docs/guides/structured-outputs/additionalproperties-false-must-always-be-set-in-objects
+    *
+    * We implement these by folding over the JSON structure. However, if a schema uses discriminated unions (indicated by a `discriminator`
+    * property), we skip forcing `additionalProperties: false` to preserve flexibility in selecting sub-schemas.
+    */
+  private val schemaFolder: Json.Folder[Json] = new Json.Folder[Json] {
+    lazy val onNull: Json = Json.Null
+    def onBoolean(value: Boolean): Json = Json.fromBoolean(value)
+    def onNumber(value: JsonNumber): Json = Json.fromJsonNumber(value)
+    def onString(value: String): Json = Json.fromString(value)
+    def onArray(value: Vector[Json]): Json = Json.fromValues(value.map(_.foldWith(this)))
+    def onObject(value: JsonObject): Json = {
+      val state = value.toList.foldRight(FolderState(Nil, addAdditionalProperties = false, Nil)) { case ((k, v), acc) =>
+        if (k == "properties")
+          acc.copy(
+            fields = (k, v.foldWith(this)) :: acc.fields,
+            addAdditionalProperties = true,
+            requiredProperties = v.asObject.fold(List.empty[String])(_.keys.toList)
+          )
+        else if (k == "type")
+          acc.copy(
+            fields = (k, v.foldWith(this)) :: acc.fields,
+            addAdditionalProperties = acc.addAdditionalProperties || v.asString.contains("object")
+          )
+        else
+          acc.copy(fields = (k, v.foldWith(this)) :: acc.fields)
+      }
+
+      // Detect if this object is part of a discriminated union by checking for a "discriminator" property.
+      val isDiscriminatedUnion = value.contains("discriminator")
+
+      val (addlPropsRemove, addlPropsAdd) =
+        if (state.addAdditionalProperties && !isDiscriminatedUnion)
+          (Set("additionalProperties"), List("additionalProperties" := false))
+        else
+          (Set(), Nil)
+
+      val (requiredRemove, requiredAdd) =
+        if (state.requiredProperties.nonEmpty)
+          (Set("required"), List("required" := state.requiredProperties))
+        else
+          (Set(), Nil)
+
+      val remove = addlPropsRemove ++ requiredRemove
+      val fields = addlPropsAdd ++ requiredAdd ++ state.fields.filterNot { case (k, _) => remove.contains(k) }
+
+      Json.fromFields(fields)
+    }
+  }
+
+}
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Message.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Message.scala
index 95bd978..0dcec2b 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Message.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Message.scala
@@ -12,6 +12,21 @@ object Message {
   case class AssistantMessage(content: String, name: Option[String] = None, toolCalls: Seq[ToolCall] = Nil) extends Message
   case class ToolMessage(content: String, toolCallId: String) extends Message
 
+  object ToolMessage {
+    def apply(content: String, toolCallId: String): ToolMessage = new ToolMessage(content, toolCallId)
+
+    def apply[T: SnakePickle.Writer](content: T, toolCallId: String): ToolMessage =
+      new ToolMessage(SnakePickle.write(content), toolCallId)
+
+    implicit val toolMessageRW: SnakePickle.ReadWriter[ToolMessage] =
+      SnakePickle
+        .readwriter[Value]
+        .bimap[ToolMessage](
+          msg => Obj("role" -> "tool", "content" -> msg.content, "tool_call_id" -> msg.toolCallId),
+          json => ToolMessage(json("content").str, json("tool_call_id").str)
+        )
+  }
+
   implicit val systemMessageRW: SnakePickle.ReadWriter[SystemMessage] =
     SnakePickle
       .readwriter[Value]
@@ -56,14 +71,6 @@ object Message {
           )
       )
 
-  implicit val toolMessageRW: SnakePickle.ReadWriter[ToolMessage] =
-    SnakePickle
-      .readwriter[Value]
-      .bimap[ToolMessage](
-        msg => Obj("role" -> "tool", "content" -> msg.content, "tool_call_id" -> msg.toolCallId),
-        json => ToolMessage(json("content").str, json("tool_call_id").str)
-      )
-
   implicit val messageRW: SnakePickle.ReadWriter[Message] =
     SnakePickle
       .readwriter[Value]
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
index 56ac63b..57dc02e 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
@@ -1,6 +1,10 @@
 package sttp.openai.requests.completions.chat.message
 
+import sttp.apispec.Schema
 import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.chat.SchemaSupport
+import sttp.tapir.docs.apispec.schema.TapirSchemaToJsonSchema
+import sttp.tapir.{Schema => TSchema}
 import ujson._
 
 sealed trait Tool
@@ -16,6 +20,34 @@ object Tool {
     */
   case class FunctionTool(description: String, name: String, parameters: Map[String, Value]) extends Tool
 
+  /** With this class we are not forced to provide Json Schema by hand. It will be automatically generated based on the type T. Please refer
+    * to readme for example of usage.
+    */
+  case class SchematizedFunctionTool(description: String, name: String, parameters: Schema) extends Tool
+
+  object SchematizedFunctionTool {
+    def apply[T: TSchema](description: String, name: String): SchematizedFunctionTool =
+      new SchematizedFunctionTool(
+        description,
+        name,
+        TapirSchemaToJsonSchema(implicitly[TSchema[T]], markOptionsAsNullable = true)
+      )
+  }
+
+  implicit val schemaRW: SnakePickle.ReadWriter[Schema] = SchemaSupport.schemaRW
+
+  implicit val schematizedFunctionToolRW: SnakePickle.ReadWriter[SchematizedFunctionTool] = SnakePickle
+    .readwriter[Value]
+    .bimap[SchematizedFunctionTool](
+      functionTool =>
+        Obj(
+          "description" -> functionTool.description,
+          "name" -> functionTool.name,
+          "parameters" -> SnakePickle.writeJs(functionTool.parameters)
+        ),
+      json => SchematizedFunctionTool(json("description").str, json("name").str, SnakePickle.read[Schema](json("parameters")))
+    )
+
   implicit val functionToolRW: SnakePickle.ReadWriter[FunctionTool] = SnakePickle
     .readwriter[Value]
     .bimap[FunctionTool](
@@ -39,6 +71,8 @@ object Tool {
     .readwriter[Value]
     .bimap[Tool](
       {
+        case schematizedFunctionTool: SchematizedFunctionTool =>
+          Obj("type" -> "function", "function" -> SnakePickle.writeJs(schematizedFunctionTool))
         case functionTool: FunctionTool =>
           Obj("type" -> "function", "function" -> SnakePickle.writeJs(functionTool))
         case CodeInterpreterTool =>
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 709611c..4c02548 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -7,7 +7,7 @@ object Dependencies {
     val scalaTestCats = "1.6.0"
 
     val sttpApispec = "0.11.7"
-    val sttpClient = "4.0.0-RC1"
+    val sttpClient = "4.0.0-RC2"
     val pekkoStreams = "1.1.3"
     val akkaStreams = "2.6.20"
     val tapir = "1.11.17"


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/ToolFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ToolFixture.scala
new file mode 100644
index 0000000..018e01c
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/ToolFixture.scala
@@ -0,0 +1,53 @@
+package sttp.openai.fixtures
+
+object ToolFixture {
+
+  val jsonToolCall: String =
+    """{
+      |  "description": "Books a flight for a passenger with full details",
+      |  "name": "book_flight",
+      |  "parameters": {
+      |    "additionalProperties": false,
+      |    "required": [
+      |      "passenger",
+      |      "departureCity",
+      |      "destinationCity"
+      |    ],
+      |    "$schema": "http://json-schema.org/draft/2020-12/schema#",
+      |    "$defs": {
+      |      "Passenger": {
+      |        "additionalProperties": false,
+      |        "required": [
+      |          "name",
+      |          "age"
+      |        ],
+      |        "title": "Passenger",
+      |        "type": "object",
+      |        "properties": {
+      |          "name": {
+      |            "type": "string"
+      |          },
+      |          "age": {
+      |            "type": "integer",
+      |            "format": "int32"
+      |          }
+      |        }
+      |      }
+      |    },
+      |    "title": "FlightDetails",
+      |    "type": "object",
+      |    "properties": {
+      |      "passenger": {
+      |        "$ref": "#/$defs/Passenger"
+      |      },
+      |      "departureCity": {
+      |        "type": "string"
+      |      },
+      |      "destinationCity": {
+      |        "type": "string"
+      |      }
+      |    }
+      |  }
+      |}""".stripMargin
+
+}
diff --git a/core/src/test/scala/sttp/openai/requests/completions/chat/ToolSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/chat/ToolSpec.scala
new file mode 100644
index 0000000..2cbfd3b
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/completions/chat/ToolSpec.scala
@@ -0,0 +1,26 @@
+package sttp.openai.requests.completions.chat
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures.ToolFixture
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.chat.message.Tool.SchematizedFunctionTool
+
+class ToolSpec extends AnyFlatSpec with Matchers with EitherValues {
+  case class Passenger(name: String, age: Int)
+
+  case class FlightDetails(passenger: Passenger, departureCity: String, destinationCity: String)
+
+  "Given SchematizedToolCall" should "be properly serialized to Json" in {
+    import sttp.tapir.generic.auto._
+    // given
+    val functionTool =
+      SchematizedFunctionTool[FlightDetails](name = "book_flight", description = "Books a flight for a passenger with full details")
+    val expectedJson = ujson.read(ToolFixture.jsonToolCall)
+    // when
+    val serializedToolCall = SnakePickle.writeJs(functionTool)
+    // then
+    serializedToolCall shouldBe expectedJson
+  }
+}