Repository: softwaremill/sttp-openai (scala)

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

Pull request description:
Closes #22

Patch:
diff --git a/core/src/main/scala/sttp/openai/OpenAi.scala b/core/src/main/scala/sttp/openai/OpenAi.scala
index c0634b3e..ac1a1f6d 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -19,6 +19,8 @@ import sttp.openai.requests.images.creation.ImageCreationRequestBody.ImageCreati
 import sttp.openai.requests.images.edit.ImageEditsConfig
 import sttp.openai.requests.images.variations.ImageVariationsConfig
 import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
+import sttp.openai.requests.moderations.ModerationsRequestBody.ModerationsBody
+import sttp.openai.requests.moderations.ModerationsResponseData.ModerationData
 import sttp.openai.requests.audio.AudioResponseData.AudioResponse
 import sttp.openai.requests.audio.transcriptions.TranscriptionConfig
 import sttp.openai.requests.audio.Model
@@ -328,6 +330,17 @@ class OpenAi(authToken: String) {
       .get(OpenAIEndpoints.retrieveFileEndpoint(fileId))
       .response(asJsonSnake[FileData])
 
+  /** @param moderationsBody
+    *   Moderation request body.
+    * @return
+    *   Classifies if text violates OpenAI's Content Policy
+    */
+  def createModeration(moderationsBody: ModerationsBody) =
+    openApiAuthRequest
+      .post(OpenAIEndpoints.ModerationsEndpoint)
+      .body(moderationsBody)
+      .response(asJsonSnake[ModerationData])
+
   /** Transcribes audio into the input language
     *
     * @param file
@@ -457,6 +470,7 @@ private object OpenAIEndpoints {
   val FilesEndpoint: Uri = uri"https://api.openai.com/v1/files"
   val FineTunesEndpoint: Uri = uri"https://api.openai.com/v1/fine-tunes"
   val ModelEndpoint: Uri = uri"https://api.openai.com/v1/models"
+  val ModerationsEndpoint: Uri = uri"https://api.openai.com/v1/moderations"
   val TranscriptionEndpoint: Uri = AudioEndpoint.addPath("transcriptions")
   val VariationsImageEndpoint: Uri = ImageEndpointBase.addPath("variations")
 
diff --git a/core/src/main/scala/sttp/openai/requests/moderations/ModerationsRequestBody.scala b/core/src/main/scala/sttp/openai/requests/moderations/ModerationsRequestBody.scala
new file mode 100644
index 00000000..f630fb14
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/moderations/ModerationsRequestBody.scala
@@ -0,0 +1,32 @@
+package sttp.openai.requests.moderations
+
+import sttp.openai.json.SnakePickle
+object ModerationsRequestBody {
+
+  /** @param input
+    *   The input text to classify.
+    * @param model
+    *   Specifies content moderation models.
+    */
+  case class ModerationsBody(input: String, model: Option[ModerationModel] = None)
+
+  object ModerationsBody {
+    implicit val moderationsBodyWriter: SnakePickle.Writer[ModerationsBody] = SnakePickle.macroW[ModerationsBody]
+  }
+  sealed trait ModerationModel
+  object ModerationModel {
+    case object TextModerationStable extends ModerationModel
+
+    case object TextModerationLatest extends ModerationModel
+
+    case class Custom(value: String) extends ModerationModel
+
+    implicit val moderationsBodyWriter: SnakePickle.Writer[ModerationModel] =
+      SnakePickle.writer[String].comap[ModerationModel] {
+        case TextModerationStable => "text-moderation-stable"
+        case TextModerationLatest => "text-moderation-latest"
+        case Custom(value)        => value
+      }
+
+  }
+}
diff --git a/core/src/main/scala/sttp/openai/requests/moderations/ModerationsResponseData.scala b/core/src/main/scala/sttp/openai/requests/moderations/ModerationsResponseData.scala
new file mode 100644
index 00000000..20b2c05a
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/moderations/ModerationsResponseData.scala
@@ -0,0 +1,49 @@
+package sttp.openai.requests.moderations
+
+import sttp.openai.json.SnakePickle
+
+object ModerationsResponseData {
+  case class CategoryScores(
+      sexual: Double,
+      hate: Double,
+      violence: Double,
+      `self-harm`: Double,
+      `sexual/minors`: Double,
+      `hate/threatening`: Double,
+      `violence/graphic`: Double
+  )
+
+  object CategoryScores {
+    implicit val categoryScoresInfoReader: SnakePickle.Reader[CategoryScores] = SnakePickle.macroR[CategoryScores]
+  }
+
+  case class Categories(
+      sexual: Boolean,
+      hate: Boolean,
+      violence: Boolean,
+      `self-harm`: Boolean,
+      `sexual/minors`: Boolean,
+      `hate/threatening`: Boolean,
+      `violence/graphic`: Boolean
+  )
+  object Categories {
+    implicit val categoriesInfoReader: SnakePickle.Reader[Categories] = SnakePickle.macroR[Categories]
+  }
+  case class Result(
+      flagged: Boolean,
+      categories: Categories,
+      categoryScores: CategoryScores
+  )
+
+  object Result {
+    implicit val resultsInfoReader: SnakePickle.Reader[Result] = SnakePickle.macroR[Result]
+  }
+  case class ModerationData(
+      id: String,
+      model: String,
+      results: Seq[Result]
+  )
+  object ModerationData {
+    implicit val morderationInfoReader: SnakePickle.Reader[ModerationData] = SnakePickle.macroR[ModerationData]
+  }
+}


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/ModerationsFixture.scala b/core/src/test/scala/sttp/openai/fixtures/ModerationsFixture.scala
new file mode 100644
index 00000000..3df8093a
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/ModerationsFixture.scala
@@ -0,0 +1,33 @@
+package sttp.openai.fixtures
+
+object ModerationsFixture {
+
+  val jsonCreateModerationResponse = """{
+                                       |  "id": "modr-5MWoLO",
+                                       |  "model": "text-moderation-001",
+                                       |  "results": [
+                                       |    {
+                                       |      "categories": {
+                                       |        "hate": false,
+                                       |        "hate/threatening": true,
+                                       |        "self-harm": false,
+                                       |        "sexual": false,
+                                       |        "sexual/minors": false,
+                                       |        "violence": true,
+                                       |        "violence/graphic": false
+                                       |      },
+                                       |      "category_scores": {
+                                       |        "hate": 0.22714105248451233,
+                                       |        "hate/threatening": 0.4132447838783264,
+                                       |        "self-harm": 0.005232391878962517,
+                                       |        "sexual": 0.01407341007143259,
+                                       |        "sexual/minors": 0.0038522258400917053,
+                                       |        "violence": 0.9223177433013916,
+                                       |        "violence/graphic": 0.036865197122097015
+                                       |      },
+                                       |      "flagged": true
+                                       |    }
+                                       |  ]
+                                       |}
+                                       |""".stripMargin
+}
diff --git a/core/src/test/scala/sttp/openai/requests/moderations/ModerationsDataSpec.scala b/core/src/test/scala/sttp/openai/requests/moderations/ModerationsDataSpec.scala
new file mode 100644
index 00000000..95fcb56f
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/moderations/ModerationsDataSpec.scala
@@ -0,0 +1,49 @@
+package sttp.openai.requests.moderations
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.fixtures
+import sttp.openai.json.SttpUpickleApiExtension
+import sttp.openai.requests.moderations.ModerationsResponseData._
+
+class ModerationsDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+  "Given create moderation response as Json" should "be properly deserialized to case class" in {
+    // given
+    val listFilesResponse = fixtures.ModerationsFixture.jsonCreateModerationResponse
+    val expectedResponse = ModerationData(
+      id = "modr-5MWoLO",
+      model = "text-moderation-001",
+      results = Seq(
+        Result(
+          flagged = true,
+          categories = Categories(
+            sexual = false,
+            hate = false,
+            violence = true,
+            `self-harm` = false,
+            `sexual/minors` = false,
+            `hate/threatening` = true,
+            `violence/graphic` = false
+          ),
+          categoryScores = CategoryScores(
+            sexual = 0.01407341007143259,
+            hate = 0.22714105248451233,
+            violence = 0.9223177433013916,
+            `self-harm` = 0.005232391878962517,
+            `sexual/minors` = 0.0038522258400917053,
+            `hate/threatening` = 0.4132447838783264,
+            `violence/graphic` = 0.036865197122097015
+          )
+        )
+      )
+    )
+    // when
+    val givenResponse: Either[Exception, ModerationData] =
+      SttpUpickleApiExtension.deserializeJsonSnake[ModerationData].apply(listFilesResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+}