Repository: softwaremill/sttp-openai (scala)

Issue:
Create chat completion
Given uri:
`https://api.openai.com/v1/chat/completions`

Create sttp POST endpoint + tests

Docs reference
https://platform.openai.com/docs/api-reference/chat

Pull request description:
Closes #5

Patch:
diff --git a/core/src/main/scala/sttp/openai/Main.scala b/core/src/main/scala/sttp/openai/Main.scala
index 53644a73..d34594e8 100644
--- a/core/src/main/scala/sttp/openai/Main.scala
+++ b/core/src/main/scala/sttp/openai/Main.scala
@@ -10,5 +10,4 @@ object Main extends App {
 //    openAi.getModels
 //      .send(backend)
 
-
 }
diff --git a/core/src/main/scala/sttp/openai/OpenAi.scala b/core/src/main/scala/sttp/openai/OpenAi.scala
index 2ad71b1b..c8d4c608 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -2,12 +2,13 @@ package sttp.openai
 
 import sttp.client4._
 import sttp.model.Uri
-import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
-import sttp.openai.json.SttpUpickleApiExtension.asJsonSnake
+import sttp.openai.json.SttpUpickleApiExtension.{asJsonSnake, upickleBodySerializerSnake}
 import sttp.openai.requests.completions.CompletionsRequestBody.CompletionsBody
-import sttp.openai.json.SttpUpickleApiExtension.upickleBodySerializerSnake
 import sttp.openai.requests.completions.CompletionsResponseData.CompletionsResponse
+import sttp.openai.requests.completions.chat.ChatRequestBody.ChatBody
+import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatResponse
 import sttp.openai.requests.files.FilesResponseData._
+import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
 
 class OpenAi(authToken: String) {
 
@@ -45,6 +46,17 @@ class OpenAi(authToken: String) {
       .get(OpenAIEndpoints.FilesEndpoint)
       .response(asJsonSnake[FilesResponse])
 
+  /** @param chatBody
+    *   Chat request body
+    *
+    * Creates a completion for the chat message given in request body and send it over to [[https://api.openai.com/v1/chat/completions]]
+    */
+  def createChatCompletion(chatBody: ChatBody): Request[Either[ResponseException[String, Exception], ChatResponse]] =
+    openApiAuthRequest
+      .post(OpenAIEndpoints.ChatEndpoint)
+      .body(chatBody)
+      .response(asJsonSnake[ChatResponse])
+
   /** @param fileId
     *   The ID of the file to use for this request.
     * @return
@@ -60,6 +72,7 @@ class OpenAi(authToken: String) {
 }
 
 private object OpenAIEndpoints {
+  val ChatEndpoint: Uri = uri"https://api.openai.com/v1/chat/completions"
   val CompletionsEndpoint: Uri = uri"https://api.openai.com/v1/completions"
   val FilesEndpoint: Uri = uri"https://api.openai.com/v1/files"
   val ModelEndpoint: Uri = uri"https://api.openai.com/v1/models"
diff --git a/core/src/main/scala/sttp/openai/requests/completions/CompletionsRequestBody.scala b/core/src/main/scala/sttp/openai/requests/completions/CompletionsRequestBody.scala
index 43fb9da0..0e6f9524 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/CompletionsRequestBody.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/CompletionsRequestBody.scala
@@ -48,24 +48,4 @@ object CompletionsRequestBody {
   }
   case class SinglePrompt(value: String) extends Prompt
   case class MultiplePrompt(values: Seq[String]) extends Prompt
-
-  sealed trait Stop
-  object Stop {
-    implicit val stopRW: SnakePickle.ReadWriter[Stop] = SnakePickle
-      .readwriter[ujson.Value]
-      .bimap[Stop](
-        {
-          case SingleStop(value)    => SnakePickle.writeJs(value)
-          case MultipleStop(values) => SnakePickle.writeJs(values)
-        },
-        json =>
-          SnakePickle.read[ujson.Value](json) match {
-            case Str(value)  => SingleStop(value)
-            case Arr(values) => MultipleStop(values.map(_.str).toSeq)
-            case e           => throw DeserializationException(e.str, new Exception(s"Could not deserialize: $e"))
-          }
-      )
-  }
-  case class SingleStop(value: String) extends Stop
-  case class MultipleStop(values: Seq[String]) extends Stop
 }
diff --git a/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala b/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala
index 00a3bcb4..a0864f64 100644
--- a/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/completions/CompletionsResponseData.scala
@@ -24,9 +24,4 @@ object CompletionsResponseData {
   object CompletionsResponse {
     implicit val choicesRW: SnakePickle.ReadWriter[CompletionsResponse] = SnakePickle.macroRW[CompletionsResponse]
   }
-
-  case class Usage(promptTokens: Int, completionTokens: Int, totalTokens: Int)
-  object Usage {
-    implicit val choicesRW: SnakePickle.ReadWriter[Usage] = SnakePickle.macroRW[Usage]
-  }
 }
diff --git a/core/src/main/scala/sttp/openai/requests/completions/Stop.scala b/core/src/main/scala/sttp/openai/requests/completions/Stop.scala
new file mode 100644
index 00000000..9343963c
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/Stop.scala
@@ -0,0 +1,27 @@
+package sttp.openai.requests.completions
+
+import sttp.client4.DeserializationException
+import sttp.openai.json.SnakePickle
+import ujson.{Arr, Str}
+
+sealed trait Stop
+object Stop {
+  implicit val stopRW: SnakePickle.ReadWriter[Stop] = SnakePickle
+    .readwriter[ujson.Value]
+    .bimap[Stop](
+      {
+        case SingleStop(value)    => SnakePickle.writeJs(value)
+        case MultipleStop(values) => SnakePickle.writeJs(values)
+      },
+      json =>
+        SnakePickle.read[ujson.Value](json) match {
+          case Str(value)  => SingleStop(value)
+          case Arr(values) => MultipleStop(values.map(_.str).toSeq)
+          case e           => throw DeserializationException(e.str, new Exception(s"Could not deserialize: $e"))
+        }
+    )
+
+  case class SingleStop(value: String) extends Stop
+
+  case class MultipleStop(values: Seq[String]) extends Stop
+}
diff --git a/core/src/main/scala/sttp/openai/requests/completions/Usage.scala b/core/src/main/scala/sttp/openai/requests/completions/Usage.scala
new file mode 100644
index 00000000..2db8c88f
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/Usage.scala
@@ -0,0 +1,9 @@
+package sttp.openai.requests.completions
+
+import sttp.openai.json.SnakePickle
+
+case class Usage(promptTokens: Int, completionTokens: Int, totalTokens: Int)
+
+object Usage {
+  implicit val choicesRW: SnakePickle.ReadWriter[Usage] = SnakePickle.macroRW[Usage]
+}
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
new file mode 100644
index 00000000..42ee19e0
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestBody.scala
@@ -0,0 +1,27 @@
+package sttp.openai.requests.completions.chat
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.Stop
+
+object ChatRequestBody {
+
+  case class ChatBody(
+      model: String,
+      messages: Seq[Message],
+      temperature: Option[Double] = None,
+      topP: Option[Double] = None,
+      n: Option[Int] = None,
+      stream: Option[Boolean] = None,
+      stop: Option[Stop] = None,
+      maxTokens: Option[Int] = None,
+      presencePenalty: Option[Double] = None,
+      frequencyPenalty: Option[Double] = None,
+      logitBias: Option[Map[String, Float]] = None,
+      user: Option[String] = None
+  )
+
+  object ChatBody {
+    implicit val chatRequestRW: SnakePickle.ReadWriter[ChatBody] = SnakePickle.macroRW[ChatBody]
+  }
+
+}
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
new file mode 100644
index 00000000..2f8f51a1
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/ChatRequestResponseData.scala
@@ -0,0 +1,30 @@
+package sttp.openai.requests.completions.chat
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.Usage
+
+object ChatRequestResponseData {
+  case class Choices(
+      message: Message,
+      finishReason: String,
+      index: Int
+  )
+
+  object Choices {
+    implicit val choicesRW: SnakePickle.ReadWriter[Choices] = SnakePickle.macroRW[Choices]
+  }
+
+  case class ChatResponse(
+      id: String,
+      `object`: String,
+      created: Int,
+      model: String,
+      usage: Usage,
+      choices: Seq[Choices]
+  )
+
+  object ChatResponse {
+    implicit val chatResponseRW: SnakePickle.ReadWriter[ChatResponse] = SnakePickle.macroRW[ChatResponse]
+  }
+
+}
diff --git a/core/src/main/scala/sttp/openai/requests/completions/chat/Message.scala b/core/src/main/scala/sttp/openai/requests/completions/chat/Message.scala
new file mode 100644
index 00000000..d11eaeda
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/chat/Message.scala
@@ -0,0 +1,9 @@
+package sttp.openai.requests.completions.chat
+
+import sttp.openai.json.SnakePickle
+
+case class Message(role: String, content: String)
+
+object Message {
+  implicit val messageRW: SnakePickle.ReadWriter[Message] = SnakePickle.macroRW[Message]
+}


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
new file mode 100644
index 00000000..23b75a7d
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/ChatFixture.scala
@@ -0,0 +1,41 @@
+package sttp.openai.fixtures
+
+object ChatFixture {
+
+  val jsonRequest: String = """{
+      |  "model": "gpt-3.5-turbo",
+      |  "messages": [{"role": "user", "content": "Hello!"}],
+      |  "temperature": 1,
+      |  "top_p": 1,
+      |  "n": 1,
+      |  "stream": false,
+      |  "stop": "\n",
+      |  "max_tokens": 7,
+      |  "presence_penalty": 0,
+      |  "frequency_penalty": 0,
+      |  "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": "assistance",
+      |         "content": "Hi there! How can I assist you today?"
+      |       },
+      |       "finish_reason": "stop",
+      |       "index": 0
+      |     }
+      |   ]
+      |}""".stripMargin
+
+}
diff --git a/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala
index 06768ee5..1c5f6883 100644
--- a/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/completions/CompletionsDataSpec.scala
@@ -5,6 +5,7 @@ import org.scalatest.flatspec.AnyFlatSpec
 import org.scalatest.matchers.should.Matchers
 import sttp.openai.fixtures
 import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+import sttp.openai.requests.completions.Stop.SingleStop
 
 class CompletionsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
   "Given completions response as Json" should "be properly deserialized to case class" in {
@@ -56,6 +57,7 @@ class CompletionsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
       logprobs = None,
       stop = Some(SingleStop("\n"))
     )
+
     val jsonRequest: ujson.Value = ujson.read(fixtures.CompletionsFixture.jsonSinglePromptRequest)
 
     // when
@@ -120,6 +122,7 @@ class CompletionsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
       logprobs = None,
       stop = Some(SingleStop("\n"))
     )
+
     val jsonRequest: ujson.Value = ujson.read(fixtures.CompletionsFixture.jsonMultiplePromptRequest)
 
     // when
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
new file mode 100644
index 00000000..5be9ee09
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/completions/chat/ChatDataSpec.scala
@@ -0,0 +1,86 @@
+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
+import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+import sttp.openai.requests.completions.Usage
+import sttp.openai.requests.completions.Stop.SingleStop
+
+class ChatDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+
+  "Given chat completions response as Json" should "be properly deserialized to case class" in {
+    import sttp.openai.requests.completions.chat.ChatRequestResponseData._
+    import sttp.openai.requests.completions.chat.ChatRequestResponseData.ChatResponse._
+
+    // given
+    val jsonResponse = fixtures.ChatFixture.jsonResponse
+
+    val usage: Usage = Usage(
+      promptTokens = 10,
+      completionTokens = 10,
+      totalTokens = 20
+    )
+
+    val message: Message = Message(
+      role = "assistance",
+      content = "Hi there! How can I assist you today?"
+    )
+
+    val choices: Choices = Choices(
+      message = message,
+      finishReason = "stop",
+      index = 0
+    )
+
+    val expectedResponse: ChatResponse = ChatResponse(
+      id = "chatcmpl-76FxnKOjnPkDVYTAQ1wK8iUNFJPvR",
+      `object` = "chat.completion",
+      created = 1681725687,
+      model = "gpt-3.5-turbo-0301",
+      usage = usage,
+      choices = Seq(choices)
+    )
+
+    // when
+    val givenResponse: Either[Exception, ChatResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given completions request as case class" should "be properly serialized to Json" in {
+    // given
+    val messages: Seq[Message] = Seq(
+      Message(
+        role = "user",
+        content = "Hello!"
+      )
+    )
+
+    val givenRequest = ChatRequestBody.ChatBody(
+      model = "gpt-3.5-turbo",
+      messages = messages,
+      temperature = Some(1),
+      topP = Some(1),
+      n = Some(1),
+      stream = Some(false),
+      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)
+
+    // then
+    serializedJson shouldBe jsonRequest
+
+  }
+
+}