Repository: softwaremill/sttp-openai
Pull request #136 (2023-11-17 14:41:22)

Redefine chat completion case classes
This PR redefines case classes for [Create chat completion](https://platform.openai.com/docs/api-reference/chat/create) to align with the latest OpenAI API.


Patch:
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 0000000..5b38b35
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,2 @@
+# Scala Steward: Reformat with scalafmt 3.7.17
+266a025b23369b5c3ef80bb96bccc87888c3971c
diff --git a/.scalafmt.conf b/.scalafmt.conf
index 8a64907..424d8a3 100644
--- a/.scalafmt.conf
+++ b/.scalafmt.conf
@@ -1,4 +1,4 @@
-version        = 3.7.15
+version        = 3.7.17
 maxColumn      = 140
 rewrite.rules  = [RedundantBraces, RedundantParens, SortImports]
 runner.dialect = scala3
diff --git a/README.md b/README.md
index cc835d9..2581aa8 100644
--- a/README.md
+++ b/README.md
@@ -35,8 +35,8 @@ OpenAI API Official Documentation https://platform.openai.com/docs/api-reference
 ```scala mdoc:compile-only 
 import sttp.openai.OpenAISyncClient
 import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatResponse
-import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel, Message}
-import sttp.openai.requests.completions.chat.Role
+import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
+import sttp.openai.requests.completions.chat.message._
 
 object Main extends App {
   // Create an instance of OpenAISyncClient providing your API secret-key
@@ -44,9 +44,8 @@ object Main extends App {
 
   // Create body of Chat Completions Request
   val bodyMessages: Seq[Message] = Seq(
-    Message(
-      role = Role.User,
-      content = "Hello!"
+    Message.UserMessage(
+      content = Content.TextContent("Hello!"),
     )
   )
 
@@ -90,17 +89,16 @@ import sttp.client4.httpclient.cats.HttpClientCatsBackend
 import sttp.openai.OpenAI
 import sttp.openai.OpenAIExceptions.OpenAIException
 import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatResponse
-import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel, Message}
-import sttp.openai.requests.completions.chat.Role
+import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
+import sttp.openai.requests.completions.chat.message._
 
 object Main extends IOApp {
   override def run(args: List[String]): IO[ExitCode] = {
     val openAI: OpenAI = new OpenAI("your-secret-key")
 
     val bodyMessages: Seq[Message] = Seq(
-      Message(
-        role = Role.User,
-        content = "Hello!"
+      Message.UserMessage(
+        content = Content.TextContent("Hello!"),
       )
     )
 
@@ -142,7 +140,7 @@ object Main extends IOApp {
 #### Create completion with streaming:
 
 To enable streaming support for the Chat Completion API using server-sent events, you must include the appropriate
-dependency for your chosen streaming library. We provide support for the following libraries: _Fs2_, _ZIO_, _Pekko Streams_
+dependency for your chosen streaming library. We provide support for the following libraries: _Fs2_, _ZIO_, _Akka / Pekko Streams_
 
 For example, to use `Fs2` add the following import:
 
@@ -161,17 +159,16 @@ import sttp.openai.OpenAI
 import sttp.openai.streaming.fs2._
 import sttp.openai.OpenAIExceptions.OpenAIException
 import sttp.openai.requests.completions.chat.ChatChunkRequestResponseData.ChatChunkResponse
-import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel, Message}
-import sttp.openai.requests.completions.chat.Role
+import sttp.openai.requests.completions.chat.ChatRequestBody.{ChatBody, ChatCompletionModel}
+import sttp.openai.requests.completions.chat.message._
 
 object Main extends IOApp {
   override def run(args: List[String]): IO[ExitCode] = {
     val openAI: OpenAI = new OpenAI("your-secret-key")
 
     val bodyMessages: Seq[Message] = Seq(
-      Message(
-        role = Role.User,
-        content = "Hello!"
+      Message.UserMessage(
+        content = Content.TextContent("Hello!"),
       )
     )
 
diff --git a/build.sbt b/build.sbt
index 35e21e4..0f98412 100644
--- a/build.sbt
+++ b/build.sbt
@@ -88,10 +88,6 @@ lazy val docs = (projectMatrix in file("generated-docs")) // important: it must
     mdocExtraArguments := Seq("--clean-target"),
     publishArtifact := false,
     name := "docs",
-    libraryDependencies ++= Seq(
-      "com.softwaremill.sttp.client4" %% "cats" % "4.0.0-M6",
-      "org.typelevel" %% "cats-effect" % "3.5.2"
-    ),
     evictionErrorLevel := Level.Info
   )
   .dependsOn(core, fs2, zio, akka, pekko)
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala
index 1545cb8..29a0db8 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatChunkRequestResponseData.scala
@@ -12,7 +12,7 @@ object ChatChunkRequestResponseData {
     * @param functionCall
     *   The name of the author of this message. May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters.
     */
-  case class Delta(role: Option[Role] = None, content: Option[String] = None, functionCall: Option[FunctionCall] = None)
+  case class Delta(role: Option[Role] = None, content: Option[String] = None, toolCalls: Option[Seq[ToolCall]] = None, functionCall: Option[FunctionCall] = None)
 
   object Delta {
     implicit val deltaR: SnakePickle.Reader[Delta] = SnakePickle.macroR[Delta]
@@ -30,10 +30,11 @@ object ChatChunkRequestResponseData {
 
   case class ChatChunkResponse(
       id: String,
-      `object`: String,
+      choices: Seq[Choices],
       created: Int,
       model: String,
-      choices: Seq[Choices]
+      `object`: String,
+      systemFingerprint: Option[String] = None,
   )
 
   object ChatChunkResponse {
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 2ac4935..4ad9a82 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
@@ -3,63 +3,100 @@ package sttp.openai.requests.completions.chat
 import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
 import sttp.openai.json.SnakePickle
 import sttp.openai.requests.completions.Stop
-import ujson.Str
+import sttp.openai.requests.completions.chat.message.{Message, Tool, ToolChoice}
+import ujson._
 
 object ChatRequestBody {
 
-  /** @param role
-    *   The role of the author of this message.
-    * @param content
-    *   The contents of the message.
-    * @param name
-    *   The name of the author of this message. May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters.
-    * @param functionCall
-    *   The name and arguments of a function that should be called, as generated by the model.
-    */
-  case class Message(role: Role, content: String, name: Option[String] = None, functionCall: Option[FunctionCall] = None)
+  sealed trait ResponseFormat
+
+  object ResponseFormat {
+    case object Text extends ResponseFormat
+    case object JsonObject extends ResponseFormat
+
+    implicit val textRW: SnakePickle.ReadWriter[Text.type] = SnakePickle
+      .readwriter[Value]
+      .bimap[Text.type](
+        _ => Obj("type" -> "text"),
+        _ => Text
+      )
 
-  object Message {
-    implicit val messageRW: SnakePickle.ReadWriter[Message] = SnakePickle.macroRW[Message]
+    implicit val jsonObjectRW: SnakePickle.ReadWriter[JsonObject.type] = SnakePickle
+      .readwriter[Value]
+      .bimap[JsonObject.type](
+        _ => Obj("type" -> "json_object"),
+        _ => JsonObject
+      )
+
+    implicit val responseFormatRW: SnakePickle.ReadWriter[ResponseFormat] = SnakePickle
+      .readwriter[Value]
+      .bimap[ResponseFormat](
+        {
+          case text: Text.type             => SnakePickle.writeJs(text)
+          case jsonObject: JsonObject.type => SnakePickle.writeJs(jsonObject)
+        },
+        json =>
+          json("type").str match {
+            case "text"        => SnakePickle.read[Text.type](json)
+            case "json_object" => SnakePickle.read[JsonObject.type](json)
+          }
+      )
   }
 
-  /** @param model
-    *   ID of the model to use.
-    * @param messages
+  /** @param messages
     *   A list of messages describing the conversation so far.
+    * @param model
+    *   ID of the model to use.
+    * @param frequencyPenalty
+    *   Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing
+    *   the model's likelihood to repeat the same line verbatim.
+    * @param logitBias
+    *   Modify the likelihood of specified tokens appearing in the completion.
+    * @param maxTokens
+    *   The maximum number of tokens to generate in the chat completion.
+    * @param n
+    *   How many chat completion choices to generate for each input message.
+    * @param presencePenalty
+    *   Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the
+    *   model's likelihood to talk about new topics.
+    * @param responseFormat
+    *   An object specifying the format that the model must output. Setting to {"type": "json_object"} enables JSON mode, which guarantees
+    *   the message the model generates is valid JSON.
+    * @param seed
+    *   This feature is in Beta. If specified, our system will make a best effort to sample deterministically, such that repeated requests
+    *   with the same seed and parameters should return the same result. Determinism is not guaranteed, and you should refer to the
+    *   system_fingerprint response parameter to monitor changes in the backend.
+    * @param stop
+    *   Up to 4 sequences where the API will stop generating further tokens.
     * @param temperature
     *   What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like
     *   0.2 will make it more focused and deterministic.
     * @param topP
     *   An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p
     *   probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.
-    * @param n
-    *   How many chat completion choices to generate for each input message.
-    * @param stop
-    *   Up to 4 sequences where the API will stop generating further tokens.
-    * @param maxTokens
-    *   The maximum number of tokens to generate in the chat completion.
-    * @param presencePenalty
-    *   Number between -2.0 and 2.0. Positive values penalize new tokens based on whether they appear in the text so far, increasing the
-    *   model's likelihood to talk about new topics.
-    * @param frequencyPenalty
-    *   Number between -2.0 and 2.0. Positive values penalize new tokens based on their existing frequency in the text so far, decreasing
-    *   the model's likelihood to repeat the same line verbatim.
-    * @param logitBias
-    *   Modify the likelihood of specified tokens appearing in the completion.
+    * @param tools
+    *   A list of tools the model may call. Currently, only functions are supported as a tool. Use this to provide a list of functions the
+    *   model may generate JSON inputs for.
+    * @param toolChoice
+    *   Controls which (if any) function is called by the model.
     * @param user
     *   A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse.
     */
   case class ChatBody(
-      model: ChatCompletionModel,
       messages: Seq[Message],
-      temperature: Option[Double] = None,
-      topP: Option[Double] = None,
-      n: Option[Int] = None,
-      stop: Option[Stop] = None,
-      maxTokens: Option[Int] = None,
-      presencePenalty: Option[Double] = None,
+      model: ChatCompletionModel,
       frequencyPenalty: Option[Double] = None,
       logitBias: Option[Map[String, Float]] = None,
+      maxTokens: Option[Int] = None,
+      n: Option[Int] = None,
+      presencePenalty: Option[Double] = None,
+      responseFormat: Option[ResponseFormat] = None,
+      seed: Option[Int] = None,
+      stop: Option[Stop] = None,
+      temperature: Option[Double] = None,
+      topP: Option[Double] = None,
+      tools: Option[Seq[Tool]] = None,
+      toolChoice: Option[ToolChoice] = None,
       user: Option[String] = None
   )
 
@@ -106,11 +143,16 @@ object ChatRequestBody {
 
     case class CustomChatCompletionModel(customChatCompletionModel: String) extends ChatCompletionModel(customChatCompletionModel)
 
-    val values: Set[ChatCompletionModel] = 
+    val values: Set[ChatCompletionModel] =
       Set(
-        GPT4, GPT40314, GPT432k, 
-        GPT432k0314, GPT35Turbo, GPT35Turbo0301, 
-        GPT4Turbo, GPT4TurboVision
+        GPT4,
+        GPT40314,
+        GPT432k,
+        GPT432k0314,
+        GPT35Turbo,
+        GPT35Turbo0301,
+        GPT4Turbo,
+        GPT4TurboVision
       )
 
     private val byChatModelValue = values.map(model => model.value -> model).toMap
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala
index b5021f0..8c04f08 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala
@@ -12,7 +12,7 @@ object ChatRequestResponseData {
     * @param functionCall
     *   The name and arguments of a function that should be called, as generated by the model.
     */
-  case class Message(role: Role, content: String, functionCall: Option[FunctionCall] = None)
+  case class Message(role: Role, content: String, toolCalls: Seq[ToolCall], functionCall: Option[FunctionCall] = None)
 
   object Message {
     implicit val messageRW: SnakePickle.Reader[Message] = SnakePickle.macroR[Message]
@@ -30,11 +30,12 @@ object ChatRequestResponseData {
 
   case class ChatResponse(
       id: String,
-      `object`: String,
+      choices: Seq[Choices],
       created: Int,
       model: String,
+      `object`: String,
       usage: Usage,
-      choices: Seq[Choices]
+      systemFingerprint: Option[String] = None,
   )
 
   object ChatResponse {
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/Role.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/Role.scala
index bd9a156..460ec74 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/chat/Role.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/Role.scala
@@ -13,9 +13,11 @@ object Role {
 
   case object Assistant extends Role("assistant")
 
+  case object Tool extends Role("tool")
+
   case class Custom(customRole: String) extends Role(customRole)
 
-  val values: Set[Role] = Set(System, User, Assistant)
+  val values: Set[Role] = Set(System, User, Assistant, Tool)
 
   private val byRoleValue = values.map(role => role.value -> role).toMap
 
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/ToolCall.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/ToolCall.scala
new file mode 100644
index 0000000..bc94506
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ToolCall.scala
@@ -0,0 +1,34 @@
+package sttp.openai.requests.completions.chat
+
+import sttp.openai.json.SnakePickle
+import ujson._
+
+sealed trait ToolCall
+
+object ToolCall {
+
+  /** @param id
+    *   The ID of the tool call.
+    * @param function
+    *   The function that the model called.
+    */
+  case class FunctionToolCall(id: String, function: FunctionCall) extends ToolCall
+
+  implicit val functionToolCallRW: SnakePickle.ReadWriter[FunctionToolCall] = SnakePickle
+    .readwriter[Value]
+    .bimap[FunctionToolCall](
+      functionToolCall =>
+        Obj("type" -> "function", "id" -> functionToolCall.id, "function" -> SnakePickle.writeJs(functionToolCall.function)),
+      json => FunctionToolCall(json("id").str, SnakePickle.read[FunctionCall](json("function")))
+    )
+
+  implicit val toolCallRW: SnakePickle.ReadWriter[ToolCall] = SnakePickle
+    .readwriter[Value]
+    .bimap[ToolCall](
+      { case functionToolCall: FunctionToolCall =>
+        SnakePickle.writeJs(functionToolCall)
+      },
+      json => SnakePickle.read[FunctionToolCall](json)
+    )
+
+}
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/message/Content.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Content.scala
new file mode 100644
index 0000000..9ec474c
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Content.scala
@@ -0,0 +1,66 @@
+package sttp.openai.requests.completions.chat.message
+
+import sttp.openai.OpenAIExceptions.OpenAIException.DeserializationOpenAIException
+import sttp.openai.json.SnakePickle
+import ujson._
+
+sealed trait Content
+
+object Content {
+  case class TextContent(value: String) extends Content
+  case class ArrayContent(value: Seq[ContentPart]) extends Content
+
+  implicit val contentRW: SnakePickle.ReadWriter[Content] = SnakePickle
+    .readwriter[Value]
+    .bimap[Content](
+      {
+        case TextContent(value)  => SnakePickle.writeJs(value)
+        case ArrayContent(value) => SnakePickle.writeJs(value)
+      },
+      jsonValue =>
+        SnakePickle.read[Value](jsonValue) match {
+          case Str(value) => TextContent(value)
+          case Arr(value) => ArrayContent(value.toSeq.map(SnakePickle.read[ContentPart](_)))
+          case e          => throw DeserializationOpenAIException(new Exception(s"Could not deserialize: $e"))
+        }
+    )
+
+  sealed trait ContentPart
+  case class TextContentPart(text: String) extends ContentPart
+  case class ImageContentPart(imageUrl: ImageUrl) extends ContentPart
+
+  implicit val textContentPartRW: SnakePickle.ReadWriter[TextContentPart] =
+    SnakePickle
+      .readwriter[Value]
+      .bimap[TextContentPart](
+        textPart => Obj("type" -> "text", "text" -> textPart.text),
+        json => TextContentPart(json("text").str)
+      )
+
+  implicit val imageContentPartRW: SnakePickle.ReadWriter[ImageContentPart] =
+    SnakePickle
+      .readwriter[Value]
+      .bimap[ImageContentPart](
+        imagePart => Obj("type" -> "image_url", "image_url" -> SnakePickle.writeJs(imagePart.imageUrl)),
+        json => ImageContentPart(SnakePickle.read[ImageUrl](json("image_url")))
+      )
+
+  implicit val contentPartRW: SnakePickle.ReadWriter[ContentPart] =
+    SnakePickle
+      .readwriter[Value]
+      .bimap[ContentPart](
+        {
+          case textPart: TextContentPart   => SnakePickle.writeJs(textPart)
+          case imagePart: ImageContentPart => SnakePickle.writeJs(imagePart)
+        },
+        json =>
+          json("type").str match {
+            case "text"      => SnakePickle.read[TextContentPart](json)
+            case "image_url" => SnakePickle.read[ImageContentPart](json)
+          }
+      )
+
+  case class ImageUrl(url: String, detail: Option[String] = None)
+
+  implicit val imageUrlRW: SnakePickle.ReadWriter[ImageUrl] = SnakePickle.macroRW[ImageUrl]
+}
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
new file mode 100644
index 0000000..95bd978
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Message.scala
@@ -0,0 +1,85 @@
+package sttp.openai.requests.completions.chat.message
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.chat.ToolCall
+import ujson._
+
+sealed trait Message
+
+object Message {
+  case class SystemMessage(content: String, name: Option[String] = None) extends Message
+  case class UserMessage(content: Content, name: Option[String] = None) extends 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
+
+  implicit val systemMessageRW: SnakePickle.ReadWriter[SystemMessage] =
+    SnakePickle
+      .readwriter[Value]
+      .bimap[SystemMessage](
+        msg => {
+          val baseObj = Obj("role" -> "system", "content" -> msg.content)
+          msg.name.foreach(name => baseObj("name") = name)
+          baseObj
+        },
+        json => SystemMessage(json("content").str, json.obj.get("name").map(_.str))
+      )
+
+  implicit val userMessageRW: SnakePickle.ReadWriter[UserMessage] =
+    SnakePickle
+      .readwriter[Value]
+      .bimap[UserMessage](
+        msg => {
+          val baseObj = Obj("role" -> "user", "content" -> SnakePickle.writeJs(msg.content))
+          msg.name.foreach(name => baseObj("name") = name)
+          baseObj
+        },
+        json => UserMessage(SnakePickle.read[Content](json("content")), json.obj.get("name").map(_.str))
+      )
+
+  implicit val assistantMessageRW: SnakePickle.ReadWriter[AssistantMessage] =
+    SnakePickle
+      .readwriter[Value]
+      .bimap[AssistantMessage](
+        msg => {
+          val baseObj = Obj("role" -> "assistant", "content" -> msg.content)
+          msg.name.foreach(name => baseObj("name") = name)
+          if (msg.toolCalls.nonEmpty) {
+            baseObj("tool_calls") = SnakePickle.writeJs(msg.toolCalls)
+          }
+          baseObj
+        },
+        json =>
+          AssistantMessage(
+            json("content").str,
+            json.obj.get("name").map(_.str),
+            SnakePickle.read[Seq[ToolCall]](json.obj("tool_calls"))
+          )
+      )
+
+  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]
+      .bimap[Message](
+        {
+          case msg: SystemMessage    => SnakePickle.writeJs(msg)
+          case msg: UserMessage      => SnakePickle.writeJs(msg)
+          case msg: AssistantMessage => SnakePickle.writeJs(msg)
+          case msg: ToolMessage      => SnakePickle.writeJs(msg)
+        },
+        json =>
+          json("role").str match {
+            case "system"    => SnakePickle.read[SystemMessage](json)
+            case "user"      => SnakePickle.read[UserMessage](json)
+            case "assistant" => SnakePickle.read[AssistantMessage](json)
+            case "tool"      => SnakePickle.read[ToolMessage](json)
+          }
+      )
+}
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
new file mode 100644
index 0000000..f44b4a0
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/Tool.scala
@@ -0,0 +1,34 @@
+package sttp.openai.requests.completions.chat.message
+
+import sttp.openai.json.SnakePickle
+import ujson._
+
+sealed trait Tool
+
+object Tool {
+
+  /** @param description
+    *   A description of what the function does, used by the model to choose when and how to call the function.
+    * @param name
+    *   The name of the function to be called. Must be a-z, A-Z, 0-9, or contain underscores and dashes, with a maximum length of 64.
+    * @param parameters
+    *   The parameters the functions accepts, described as a JSON Schema object
+    */
+  case class FunctionTool(description: String, name: String, parameters: Map[String, Value]) extends Tool
+
+  implicit val functionToolRW: SnakePickle.ReadWriter[FunctionTool] = SnakePickle
+    .readwriter[Value]
+    .bimap[FunctionTool](
+      functionTool => Obj("description" -> functionTool.description, "name" -> functionTool.name, "parameters" -> functionTool.parameters),
+      json => FunctionTool(json("description").str, json("name").str, json("parameters").obj.toMap)
+    )
+
+  implicit val toolRW: SnakePickle.ReadWriter[Tool] = SnakePickle
+    .readwriter[Value]
+    .bimap[Tool](
+      { case functionTool: FunctionTool =>
+        Obj("type" -> "function", "function" -> SnakePickle.writeJs(functionTool))
+      },
+      json => SnakePickle.read[FunctionTool](json("function"))
+    )
+}
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/message/ToolChoice.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/message/ToolChoice.scala
new file mode 100644
index 0000000..d94f05b
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/message/ToolChoice.scala
@@ -0,0 +1,59 @@
+package sttp.openai.requests.completions.chat.message
+
+import sttp.openai.json.SnakePickle
+import ujson._
+
+sealed trait ToolChoice
+
+object ToolChoice {
+
+  /** Means that the model will not call a function and instead generates a message. */
+  case object ToolNone extends ToolChoice
+
+  /** Means the model can pick between generating a message or calling a function. */
+  case object ToolAuto extends ToolChoice
+
+  /** Means the model will call a function. */
+  case class ToolFunction(name: String) extends ToolChoice
+
+  implicit val toolNoneRW: SnakePickle.ReadWriter[ToolNone.type] = SnakePickle
+    .readwriter[Value]
+    .bimap[ToolNone.type](
+      _ => Str("none"),
+      _ => ToolNone
+    )
+
+  implicit val toolAutoRW: SnakePickle.ReadWriter[ToolAuto.type] = SnakePickle
+    .readwriter[Value]
+    .bimap[ToolAuto.type](
+      _ => Str("auto"),
+      _ => ToolAuto
+    )
+
+  implicit val toolFunctionRW: SnakePickle.ReadWriter[ToolFunction] = SnakePickle
+    .readwriter[Value]
+    .bimap[ToolFunction](
+      toolFunction => Obj("type" -> "function", "function" -> Obj("name" -> toolFunction.name)),
+      json => ToolFunction(json.obj("function")("name").str)
+    )
+
+  implicit val toolChoiceRW: SnakePickle.ReadWriter[ToolChoice] = SnakePickle
+    .readwriter[Value]
+    .bimap[ToolChoice](
+      {
+        case toolAuto: ToolAuto.type    => SnakePickle.writeJs(toolAuto)
+        case toolNone: ToolNone.type    => SnakePickle.writeJs(toolNone)
+        case toolFunction: ToolFunction => SnakePickle.writeJs(toolFunction)
+      },
+      {
+        case json @ Str("none") => SnakePickle.read[ToolNone.type](json)
+        case json @ Str("auto") => SnakePickle.read[ToolAuto.type](json)
+        case json               => SnakePickle.read[ToolFunction](json)
+      }
+    )
+
+  case class FunctionSpec(name: String)
+
+  implicit val functionSpecRW: SnakePickle.ReadWriter[FunctionSpec] = SnakePickle.macroRW[FunctionSpec]
+
+}


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala
index f6f8705..2b92693 100644
--- a/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/ChatChunkFixture.scala
@@ -2,21 +2,146 @@ package sttp.openai.fixtures
 
 object ChatChunkFixture {
 
+  val jsonRequest: String =
+    """{
+      |  "messages": [
+      |    {
+      |      "role": "system",
+      |      "content": "Hello!"
+      |    },
+      |    {
+      |      "role": "system",
+      |      "content": "Hello!",
+      |      "name": "User"
+      |    },
+      |    {
+      |      "role": "user",
+      |      "content": "Hello!",
+      |      "name": "User"
+      |    },
+      |    {
+      |      "role": "user",
+      |      "content": [
+      |        {
+      |          "type": "text",
+      |          "text": "Hello!"
+      |        },
+      |        {
+      |          "type": "image_url",
+      |          "image_url": {
+      |            "url": "https://i.imgur.com/2tj5rQE.jpg"
+      |          }
+      |        }
+      |      ]
+      |    },
+      |    {
+      |      "role": "assistant",
+      |      "content": "Hello!",
+      |      "name": "User",
+      |      "tool_calls": [
+      |        {
+      |          "id": "tool_id_1",
+      |          "type": "function",
+      |          "function": {
+      |            "arguments": "args",
+      |            "name": "Fish"
+      |          }
+      |        },
+      |        {
+      |          "id": "tool_id_2",
+      |          "type": "function",
+      |          "function": {
+      |            "arguments": "args",
+      |            "name": "Fish"
+      |          }
+      |        }
+      |      ]
+      |    },
+      |    {
+      |      "role": "assistant",
+      |      "content": "Hello!",
+      |      "name": "User"
+      |    },
+      |    {
+      |      "role": "assistant",
+      |      "content": "Hello!"
+      |    },
+      |    {
+      |      "role": "tool",
+      |      "content": "Hello!",
+      |      "tool_call_id": "tool_call_id_1"
+      |    },
+      |    {
+      |      "role": "tool",
+      |      "content": "Hello!",
+      |      "tool_call_id": "tool_call_id_2"
+      |    }
+      |  ],
+      |  "model": "gpt-3.5-turbo",
+      |  "frequency_penalty": 0,
+      |  "max_tokens": 7,
+      |  "n": 1,
+      |  "presence_penalty": 0,
+      |  "stop": "\n",
+      |  "temperature": 1,
+      |  "top_p": 1,
+      |  "response_format": {
+      |   "type": "text"
+      |  },
+      |  "tools": [
+      |    {
+      |      "type": "function",
+      |      "function": {
+      |        "description": "Random description",
+      |        "name": "Random name",
+      |        "parameters": {
+      |          "type": "function",
+      |          "properties": {
+      |            "location": {
+      |              "type": "string",
+      |              "description": "The city and state e.g. San Francisco, CA"
+      |            }
+      |          },
+      |          "required": ["location"]
+      |        }
+      |      }
+      |    }
+      |  ],
+      |  "tool_choice": "auto",
+      |  "user": "testUser",
+      |  "stream": true
+      |}""".stripMargin
+
   val jsonResponse: String =
     """{
       |  "id": "chatcmpl-76FxnKOjnPkDVYTAQ1wK8iUNFJPvR",
       |  "object": "chat.completion",
       |  "created": 1681725687,
       |  "model": "gpt-3.5-turbo-0301",
+      |  "system_fingerprint": "systemFingerprint",
       |  "choices": [
       |    {
       |      "delta": {
       |        "role": "assistant",
       |        "content": "  Hi",
-      |        "function_call": {
-      |          "arguments": "args",
-      |          "name": "Fish"
-      |        }
+      |        "tool_calls": [
+      |         {
+      |           "id": "tool_id_1",
+      |           "type": "function",
+      |           "function": {
+      |             "arguments": "args",
+      |             "name": "Fish"
+      |           }
+      |         },
+      |         {
+      |           "id": "tool_id_2",
+      |           "type": "function",
+      |           "function": {
+      |             "arguments": "args",
+      |             "name": "Fish"
+      |           }
+      |         }
+      |        ]
       |      },
       |      "finish_reason": "stop",
       |      "index": 0
@@ -24,29 +149,4 @@ object ChatChunkFixture {
       |  ]
       |}""".stripMargin
 
-  val jsonRequest: String =
-    """{
-      |  "model": "gpt-3.5-turbo",
-      |  "messages": [
-      |    {
-      |      "role": "user",
-      |      "content": "Hello!",
-      |      "name": "Andrzej",
-      |      "function_call": {
-      |        "arguments": "args",
-      |        "name": "Fish"
-      |      }
-      |    }
-      |  ],
-      |  "temperature": 1,
-      |  "top_p": 1,
-      |  "n": 1,
-      |  "stop": "\n",
-      |  "max_tokens": 7,
-      |  "presence_penalty": 0,
-      |  "frequency_penalty": 0,
-      |  "user": "testUser",
-      |  "stream": true
-      |}""".stripMargin
-
 }
diff --git a/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
index 2c6e532..0ffe150 100644
--- a/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
@@ -2,53 +2,161 @@ package sttp.openai.fixtures
 
 object ChatFixture {
 
-  val jsonRequest: String = """{
-     |  "model": "gpt-3.5-turbo",
-     |  "messages": [
-     |    {
-     |      "role": "user",
-     |      "content": "Hello!",
-     |      "name": "Andrzej",
-     |      "function_call": {
-     |        "arguments": "args",
-     |        "name": "Fish"
-     |      }
-     |    }
-     |  ],
-     |  "temperature": 1,
-     |  "top_p": 1,
-     |  "n": 1,
-     |  "stop": "\n",
-     |  "max_tokens": 7,
-     |  "presence_penalty": 0,
-     |  "frequency_penalty": 0,
-     |  "user": "testUser"
-     |}""".stripMargin
+  val jsonRequest: String =
+    """{
+      |  "messages": [
+      |    {
+      |      "role": "system",
+      |      "content": "Hello!"
+      |    },
+      |    {
+      |      "role": "system",
+      |      "content": "Hello!",
+      |      "name": "User"
+      |    },
+      |    {
+      |      "role": "user",
+      |      "content": "Hello!",
+      |      "name": "User"
+      |    },
+      |    {
+      |      "role": "user",
+      |      "content": [
+      |        {
+      |          "type": "text",
+      |          "text": "Hello!"
+      |        },
+      |        {
+      |          "type": "image_url",
+      |          "image_url": {
+      |            "url": "https://i.imgur.com/2tj5rQE.jpg"
+      |          }
+      |        }
+      |      ]
+      |    },
+      |    {
+      |      "role": "assistant",
+      |      "content": "Hello!",
+      |      "name": "User",
+      |      "tool_calls": [
+      |        {
+      |          "id": "tool_id_1",
+      |          "type": "function",
+      |          "function": {
+      |            "arguments": "args",
+      |            "name": "Fish"
+      |          }
+      |        },
+      |        {
+      |          "id": "tool_id_2",
+      |          "type": "function",
+      |          "function": {
+      |            "arguments": "args",
+      |            "name": "Fish"
+      |          }
+      |        }
+      |      ]
+      |    },
+      |    {
+      |      "role": "assistant",
+      |      "content": "Hello!",
+      |      "name": "User"
+      |    },
+      |    {
+      |      "role": "assistant",
+      |      "content": "Hello!"
+      |    },
+      |    {
+      |      "role": "tool",
+      |      "content": "Hello!",
+      |      "tool_call_id": "tool_call_id_1"
+      |    },
+      |    {
+      |      "role": "tool",
+      |      "content": "Hello!",
+      |      "tool_call_id": "tool_call_id_2"
+      |    }
+      |  ],
+      |  "model": "gpt-3.5-turbo",
+      |  "frequency_penalty": 0,
+      |  "max_tokens": 7,
+      |  "n": 1,
+      |  "presence_penalty": 0,
+      |  "stop": "\n",
+      |  "temperature": 1,
+      |  "top_p": 1,
+      |  "response_format": {
+      |   "type": "json_object"
+      |  },
+      |  "tools": [
+      |    {
+      |      "type": "function",
+      |      "function": {
+      |        "description": "Random description",
+      |        "name": "Random name",
+      |        "parameters": {
+      |          "type": "function",
+      |          "properties": {
+      |            "location": {
+      |              "type": "string",
+      |              "description": "The city and state e.g. San Francisco, CA"
+      |            }
+      |          },
+      |          "required": ["location"]
+      |        }
+      |      }
+      |    }
+      |  ],
+      |  "tool_choice": {
+      |    "type": "function",
+      |    "function": {
+      |      "name": "function"
+      |    }
+      |  },
+      |  "user": "testUser"
+      |}""".stripMargin
 
-  val jsonResponse: String = """{
-      |   "id": "chatcmpl-76FxnKOjnPkDVYTAQ1wK8iUNFJPvR",
-      |   "object": "chat.completion",
-      |   "created": 1681725687,
-      |   "model": "gpt-3.5-turbo-0301",
-      |   "usage": {
-      |     "prompt_tokens": 10,
-      |     "completion_tokens": 10,
-      |     "total_tokens": 20
-      |   },
-      |   "choices": [
-      |     {
-      |       "message": {
-      |         "role": "assistant",
-      |         "content": "Hi there! How can I assist you today?",
-      |         "function_call": {
-      |           "arguments": "args",
-      |           "name": "Fish"
+  val jsonResponse: String =
+    """{
+      |  "id": "chatcmpl-76FxnKOjnPkDVYTAQ1wK8iUNFJPvR",
+      |  "object": "chat.completion",
+      |  "created": 1681725687,
+      |  "model": "gpt-3.5-turbo-0301",
+      |  "system_fingerprint": "systemFingerprint",
+      |  "usage": {
+      |    "prompt_tokens": 10,
+      |    "completion_tokens": 10,
+      |    "total_tokens": 20
+      |  },
+      |  "choices": [
+      |    {
+      |      "message": {
+      |        "role": "assistant",
+      |        "content": "Hi there! How can I assist you today?",
+      |        "tool_calls": [
+      |         {
+      |           "id": "tool_id_1",
+      |           "type": "function",
+      |           "function": {
+      |             "arguments": "args",
+      |             "name": "Fish"
+      |           }
+      |         },
+      |         {
+      |           "id": "tool_id_2",
+      |           "type": "function",
+      |           "function": {
+      |             "arguments": "args",
+      |             "name": "Fish"
+      |           }
       |         }
-      |       },
-      |       "finish_reason": "stop",
-      |       "index": 0
-      |     }
-      |   ]
-      |}""".stripMargin
+      |        ]
+      |      },
+      |      "finish_reason": "stop",
+      |      "index": 0
+      |    }
+      |  ]
+      |}
+      |""".stripMargin
 
 }
diff --git a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala
index c4aea24..08072cd 100644
--- a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatChunkDataSpec.scala
@@ -6,25 +6,21 @@ import org.scalatest.matchers.should.Matchers
 import sttp.openai.fixtures
 import sttp.openai.json.SttpUpickleApiExtension
 import sttp.openai.requests.completions.Stop.SingleStop
+import sttp.openai.utils.ChatCompletionFixtures._
 
 class ChatChunkDataSpec extends AnyFlatSpec with Matchers with EitherValues {
 
   "Given chat chunk completions response as Json" should "be properly deserialized to case class" in {
-    import ChatChunkRequestResponseData._
     import ChatChunkRequestResponseData.ChatChunkResponse._
+    import ChatChunkRequestResponseData._
 
     // given
     val jsonResponse = fixtures.ChatChunkFixture.jsonResponse
 
-    val functionCall: FunctionCall = FunctionCall(
-      arguments = "args",
-      name = "Fish"
-    )
-
     val delta: Delta = Delta(
       role = Some(Role.Assistant),
       content = Some("  Hi"),
-      functionCall = Some(functionCall)
+      toolCalls = Some(toolCalls)
     )
 
     val choices: Choices = Choices(
@@ -38,7 +34,8 @@ class ChatChunkDataSpec extends AnyFlatSpec with Matchers with EitherValues {
       `object` = "chat.completion",
       created = 1681725687,
       model = "gpt-3.5-turbo-0301",
-      choices = Seq(choices)
+      choices = Seq(choices),
+      systemFingerprint = Some("systemFingerprint")
     )
 
     // when
@@ -50,43 +47,31 @@ class ChatChunkDataSpec extends AnyFlatSpec with Matchers with EitherValues {
 
   "Given completions request with streaming enabled as case class" should "be properly serialized to Json" in {
     import ChatRequestBody._
+    import sttp.openai.requests.completions.chat.message._
 
     // given
-    val functionCall: FunctionCall = FunctionCall(
-      arguments = "args",
-      name = "Fish"
-    )
-
-    val messages: Seq[Message] = Seq(
-      Message(
-        role = Role.User,
-        content = "Hello!",
-        name = Some("Andrzej"),
-        functionCall = Some(functionCall)
-      )
-    )
-
     val givenRequest = ChatRequestBody.ChatBody(
-      model = ChatCompletionModel.GPT35Turbo,
       messages = messages,
+      model = ChatCompletionModel.GPT35Turbo,
+      frequencyPenalty = Some(0),
+      maxTokens = Some(7),
+      n = Some(1),
+      presencePenalty = Some(0),
       temperature = Some(1),
       topP = Some(1),
-      n = Some(1),
+      tools = Some(tools),
+      responseFormat = Some(ResponseFormat.Text),
+      toolChoice = Some(ToolChoice.ToolAuto),
       stop = Some(SingleStop("\n")),
-      maxTokens = Some(7),
-      presencePenalty = Some(0),
-      frequencyPenalty = Some(0),
       user = Some("testUser")
     )
 
-    val jsonRequest: ujson.Value = ujson.read(fixtures.ChatChunkFixture.jsonRequest)
+    val jsonRequest = ujson.read(fixtures.ChatChunkFixture.jsonRequest)
 
     // when
-    val serializedJson: ujson.Value = ChatBody.withStreaming(givenRequest)
+    val serializedJson = ChatBody.withStreaming(givenRequest)
 
     // then
     serializedJson shouldBe jsonRequest
-
   }
-
 }
diff --git a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala
index 459081c..04129b8 100644
--- a/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala
@@ -8,6 +8,7 @@ import sttp.openai.json.SnakePickle
 import sttp.openai.json.SttpUpickleApiExtension
 import sttp.openai.requests.completions.Stop.SingleStop
 import sttp.openai.requests.completions.Usage
+import sttp.openai.utils.ChatCompletionFixtures._
 
 class ChatDataSpec extends AnyFlatSpec with Matchers with EitherValues {
 
@@ -24,15 +25,10 @@ class ChatDataSpec extends AnyFlatSpec with Matchers with EitherValues {
       totalTokens = 20
     )
 
-    val functionCall: FunctionCall = FunctionCall(
-      arguments = "args",
-      name = "Fish"
-    )
-
     val message: Message = Message(
       role = Role.Assistant,
       content = "Hi there! How can I assist you today?",
-      functionCall = Some(functionCall)
+      toolCalls = toolCalls
     )
 
     val choices: Choices = Choices(
@@ -47,7 +43,8 @@ class ChatDataSpec extends AnyFlatSpec with Matchers with EitherValues {
       created = 1681725687,
       model = "gpt-3.5-turbo-0301",
       usage = usage,
-      choices = Seq(choices)
+      choices = Seq(choices),
+      systemFingerprint = Some("systemFingerprint")
     )
 
     // when
@@ -59,43 +56,31 @@ class ChatDataSpec extends AnyFlatSpec with Matchers with EitherValues {
 
   "Given completions request as case class" should "be properly serialized to Json" in {
     import ChatRequestBody._
+    import sttp.openai.requests.completions.chat.message._
 
     // given
-    val functionCall: FunctionCall = FunctionCall(
-      arguments = "args",
-      name = "Fish"
-    )
-
-    val messages: Seq[Message] = Seq(
-      Message(
-        role = Role.User,
-        content = "Hello!",
-        name = Some("Andrzej"),
-        functionCall = Some(functionCall)
-      )
-    )
-
     val givenRequest = ChatRequestBody.ChatBody(
-      model = ChatCompletionModel.GPT35Turbo,
       messages = messages,
+      model = ChatCompletionModel.GPT35Turbo,
+      frequencyPenalty = Some(0),
+      maxTokens = Some(7),
+      n = Some(1),
+      presencePenalty = Some(0),
       temperature = Some(1),
       topP = Some(1),
-      n = Some(1),
+      tools = Some(tools),
+      responseFormat = Some(ResponseFormat.JsonObject),
+      toolChoice = Some(ToolChoice.ToolFunction("function")),
       stop = Some(SingleStop("\n")),
-      maxTokens = Some(7),
-      presencePenalty = Some(0),
-      frequencyPenalty = Some(0),
       user = Some("testUser")
     )
 
     val jsonRequest: ujson.Value = ujson.read(fixtures.ChatFixture.jsonRequest)
 
     // when
-    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+    val serializedJson = SnakePickle.writeJs(givenRequest)
 
     // then
     serializedJson shouldBe jsonRequest
-
   }
-
 }
diff --git a/core/src/test/scala/sttp/openai/utils/ChatCompletionFixtures.scala b/core/src/test/scala/sttp/openai/utils/ChatCompletionFixtures.scala
new file mode 100644
index 0000000..e0b3fea
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/utils/ChatCompletionFixtures.scala
@@ -0,0 +1,69 @@
+package sttp.openai.utils
+
+import sttp.openai.requests.completions.chat.ToolCall.FunctionToolCall
+import sttp.openai.requests.completions.chat.message.Tool.FunctionTool
+import sttp.openai.requests.completions.chat.{FunctionCall, ToolCall}
+import sttp.openai.requests.completions.chat.message._
+import ujson._
+
+object ChatCompletionFixtures {
+  def messages: Seq[Message] = systemMessages ++ userMessages ++ assistantMessages ++ toolMessages
+
+  def systemMessages: Seq[Message.SystemMessage] =
+    Seq(Message.SystemMessage("Hello!"), Message.SystemMessage("Hello!", Some("User")))
+
+  def userMessages: Seq[Message.UserMessage] = {
+    val parts = Seq(
+      Content.TextContentPart("Hello!"),
+      Content.ImageContentPart(Content.ImageUrl("https://i.imgur.com/2tj5rQE.jpg"))
+    )
+    val arrayMessage = Message.UserMessage(Content.ArrayContent(parts))
+    val stringMessage = Message.UserMessage(Content.TextContent("Hello!"), Some("User"))
+
+    Seq(stringMessage, arrayMessage)
+  }
+
+  def assistantMessages: Seq[Message.AssistantMessage] =
+    Seq(
+      Message.AssistantMessage("Hello!", Some("User"), toolCalls),
+      Message.AssistantMessage("Hello!", Some("User")),
+      Message.AssistantMessage("Hello!")
+    )
+
+  def toolMessages: Seq[Message.ToolMessage] =
+    Seq(
+      Message.ToolMessage("Hello!", "tool_call_id_1"),
+      Message.ToolMessage("Hello!", "tool_call_id_2")
+    )
+
+  def tools: Seq[Tool] = {
+    val function = FunctionTool(
+      description = "Random description",
+      name = "Random name",
+      parameters = Map(
+        "type" -> Str("function"),
+        "properties" -> Obj(
+          "location" -> Obj(
+            "type" -> "string",
+            "description" -> "The city and state e.g. San Francisco, CA"
+          )
+        ),
+        "required" -> Arr("location")
+      )
+    )
+
+    Seq(function)
+  }
+
+  def toolCalls: Seq[ToolCall] = {
+    val functionCall: FunctionCall = FunctionCall(
+      arguments = "args",
+      name = "Fish"
+    )
+
+    Seq(
+      FunctionToolCall("tool_id_1", functionCall),
+      FunctionToolCall("tool_id_2", functionCall)
+    )
+  }
+}