Repository: softwaremill/sttp-openai (scala)

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

Pull request description:
Should be merged after: 
  Create chat completion #32 (https://github.com/softwaremill/sttp-openai/pull/32)

Closes #6

Patch:
diff --git a/core/src/main/scala/sttp/openai/OpenAi.scala b/core/src/main/scala/sttp/openai/OpenAi.scala
index c8d4c608..73f07d63 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -7,6 +7,8 @@ import sttp.openai.requests.completions.CompletionsRequestBody.CompletionsBody
 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.completions.edit.EditRequestBody.EditBody
+import sttp.openai.requests.completions.edit.EditRequestResponseData.EditResponse
 import sttp.openai.requests.files.FilesResponseData._
 import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
 
@@ -46,6 +48,17 @@ class OpenAi(authToken: String) {
       .get(OpenAIEndpoints.FilesEndpoint)
       .response(asJsonSnake[FilesResponse])
 
+  /** @param editRequestBody
+    *   Edit request body
+    *
+    * Creates a new edit for provided request body and send it over to [[https://api.openai.com/v1/chat/completions]]
+    */
+  def createEdit(editRequestBody: EditBody): Request[Either[ResponseException[String, Exception], EditResponse]] =
+    openApiAuthRequest
+      .post(OpenAIEndpoints.EditEndpoint)
+      .body(editRequestBody)
+      .response(asJsonSnake[EditResponse])
+
   /** @param chatBody
     *   Chat request body
     *
@@ -74,8 +87,10 @@ 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 EditEndpoint: Uri = uri"https://api.openai.com/v1/edits"
   val FilesEndpoint: Uri = uri"https://api.openai.com/v1/files"
   val ModelEndpoint: Uri = uri"https://api.openai.com/v1/models"
+
   def retrieveFileEndpoint(fileId: String): Uri = FilesEndpoint.addPath(fileId)
   def retrieveModelEndpoint(modelId: String): Uri = ModelEndpoint.addPath(modelId)
 }
diff --git a/core/src/main/scala/sttp/openai/requests/completions/edit/EditRequestBody.scala b/core/src/main/scala/sttp/openai/requests/completions/edit/EditRequestBody.scala
new file mode 100644
index 00000000..512a4a21
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/edit/EditRequestBody.scala
@@ -0,0 +1,20 @@
+package sttp.openai.requests.completions.edit
+
+import sttp.openai.json.SnakePickle
+
+object EditRequestBody {
+
+  case class EditBody(
+      model: String,
+      input: Option[String] = None,
+      instruction: String,
+      n: Option[Int] = None,
+      temperature: Option[Double] = None,
+      topP: Option[Double] = None
+  )
+
+  object EditBody {
+    implicit val editBodyRW: SnakePickle.ReadWriter[EditBody] = SnakePickle.macroRW[EditBody]
+  }
+
+}
diff --git a/core/src/main/scala/sttp/openai/requests/completions/edit/EditRequestResponseData.scala b/core/src/main/scala/sttp/openai/requests/completions/edit/EditRequestResponseData.scala
new file mode 100644
index 00000000..d62902a3
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/completions/edit/EditRequestResponseData.scala
@@ -0,0 +1,28 @@
+package sttp.openai.requests.completions.edit
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.completions.Usage
+
+object EditRequestResponseData {
+
+  case class EditResponse(
+      `object`: String,
+      created: Int,
+      choices: Seq[Choices],
+      usage: Usage
+  )
+
+  object EditResponse {
+    implicit val editResponseRW: SnakePickle.ReadWriter[EditResponse] = SnakePickle.macroRW[EditResponse]
+  }
+
+  case class Choices(
+      text: String,
+      index: Int
+  )
+
+  object Choices {
+    implicit val choicesRW: SnakePickle.ReadWriter[Choices] = SnakePickle.macroRW[Choices]
+  }
+
+}


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/EditFixture.scala b/core/src/test/scala/sttp/openai/fixtures/EditFixture.scala
new file mode 100644
index 00000000..549764bf
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/EditFixture.scala
@@ -0,0 +1,27 @@
+package sttp.openai.fixtures
+
+object EditFixture {
+
+  val jsonResponse: String = """{
+      |  "object": "edit",
+      |  "created": 1681798630,
+      |  "choices": [
+      |    {
+      |      "text": "What day of the week is it?",
+      |      "index": 0
+      |    }
+      |  ],
+      |  "usage": {
+      |    "prompt_tokens": 25,
+      |    "completion_tokens": 32,
+      |    "total_tokens": 57
+      |  }
+      |}""".stripMargin
+
+  val jsonRequest: String = """{
+      |"model": "text-davinci-edit-001",
+      |"input": "What day of the wek is it?",
+      |"instruction": "Fix the spelling mistakes"
+      |}""".stripMargin
+
+}
diff --git a/core/src/test/scala/sttp/openai/requests/completions/EditDataSpec.scala b/core/src/test/scala/sttp/openai/requests/completions/EditDataSpec.scala
new file mode 100644
index 00000000..165e117a
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/completions/EditDataSpec.scala
@@ -0,0 +1,61 @@
+package sttp.openai.requests.completions
+
+import sttp.openai.fixtures
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+import sttp.openai.requests.completions.edit.EditRequestBody._
+
+class EditDataSpec 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.edit.EditRequestResponseData._
+    import sttp.openai.requests.completions.edit.EditRequestResponseData.EditResponse._
+
+    // given
+    val jsonResponse = fixtures.EditFixture.jsonResponse
+
+    val choices: Choices = Choices(
+      text = "What day of the week is it?",
+      index = 0
+    )
+
+    val usage: Usage = Usage(
+      promptTokens = 25,
+      completionTokens = 32,
+      totalTokens = 57
+    )
+
+    val expectedResponse: EditResponse = EditResponse(
+      `object` = "edit",
+      created = 1681798630,
+      choices = Seq(choices),
+      usage = usage
+    )
+
+    // when
+    val givenResponse: Either[Exception, EditResponse] = SttpUpickleApiExtension.deserializeJsonSnake.apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given completions request as case class" should "be properly serialized to Json" in {
+    // given
+    val givenRequest: EditBody = EditBody(
+      model = "text-davinci-edit-001",
+      input = Some("What day of the wek is it?"),
+      instruction = "Fix the spelling mistakes"
+    )
+
+    val jsonRequest: ujson.Value = ujson.read(fixtures.EditFixture.jsonRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+
+}