Repository: softwaremill/sttp-openai (scala)

Issue:
List fine-tunes
Given uri:
`https://api.openai.com/v1/fine-tunes`

Create sttp GET endpoint + tests

Docs reference
https://platform.openai.com/docs/api-reference/fine-tunes/list

Pull request description:
Should be merged after #34

Patch:
diff --git a/core/src/main/scala/sttp/openai/Main.scala b/core/src/main/scala/sttp/openai/Main.scala
index d8d2aa75..b8683246 100644
--- a/core/src/main/scala/sttp/openai/Main.scala
+++ b/core/src/main/scala/sttp/openai/Main.scala
@@ -10,4 +10,4 @@ object Main extends App {
   //    openAi.getModels
   //      .send(backend)
 
-}
\ No newline at end of file
+}
diff --git a/core/src/main/scala/sttp/openai/OpenAi.scala b/core/src/main/scala/sttp/openai/OpenAi.scala
index c1536f33..614c04a8 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -11,7 +11,7 @@ import sttp.openai.requests.completions.edit.EditRequestBody.EditBody
 import sttp.openai.requests.completions.edit.EditRequestResponseData.EditResponse
 import sttp.openai.requests.files.FilesResponseData.{DeletedFileData, FileData, FilesResponse}
 import sttp.openai.requests.finetunes.FineTunesRequestBody
-import sttp.openai.requests.finetunes.FineTunesResponseData.FineTuneResponse
+import sttp.openai.requests.finetunes.FineTunesResponseData.{CreateFineTuneResponse, GetFineTunesResponse}
 import sttp.openai.requests.images.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.ImageCreationResponseData.ImageCreationResponse
 import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
@@ -111,11 +111,21 @@ class OpenAi(authToken: String) {
     * @return
     *   Details of the enqueued job including job status and the name of the fine-tuned models once complete.
     */
-  def createFineTune(fineTunesRequestBody: FineTunesRequestBody): Request[Either[ResponseException[String, Exception], FineTuneResponse]] =
+  def createFineTune(
+      fineTunesRequestBody: FineTunesRequestBody
+  ): Request[Either[ResponseException[String, Exception], CreateFineTuneResponse]] =
     openApiAuthRequest
       .post(OpenAIEndpoints.FineTunesEndpoint)
       .body(fineTunesRequestBody)
-      .response(asJsonSnake[FineTuneResponse])
+      .response(asJsonSnake[CreateFineTuneResponse])
+
+  /** @return
+    *   List of your organization's fine-tuning jobs.
+    */
+  def getFineTunes: Request[Either[ResponseException[String, Exception], GetFineTunesResponse]] =
+    openApiAuthRequest
+      .get(OpenAIEndpoints.FineTunesEndpoint)
+      .response(asJsonSnake[GetFineTunesResponse])
 
   private val openApiAuthRequest: PartialRequest[Either[String, String]] = basicRequest.auth
     .bearer(authToken)
diff --git a/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesRequestBody.scala b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesRequestBody.scala
index 4c8bde15..6ab8449d 100644
--- a/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesRequestBody.scala
+++ b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesRequestBody.scala
@@ -49,5 +49,5 @@ case class FineTunesRequestBody(
     suffix: Option[String] = None
 )
 object FineTunesRequestBody {
-  implicit val fineTunesRequestBodyReadWriter: SnakePickle.ReadWriter[FineTunesRequestBody] = SnakePickle.macroRW[FineTunesRequestBody]
+  implicit val fineTunesRequestBodyWriter: SnakePickle.Writer[FineTunesRequestBody] = SnakePickle.macroW[FineTunesRequestBody]
 }
diff --git a/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
index c1cb2b91..2133e5bb 100644
--- a/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
@@ -11,20 +11,38 @@ object FineTunesResponseData {
       createdAt: Int
   )
   object Event {
-    implicit val eventsBodyReadWriter: SnakePickle.ReadWriter[Event] = SnakePickle.macroRW[Event]
+    implicit val eventsBodyReader: SnakePickle.Reader[Event] = SnakePickle.macroR[Event]
   }
 
   case class Hyperparams(
       nEpochs: Int,
-      batchSize: Option[String],
+      batchSize: Option[Int],
       promptLossWeight: Double,
-      learningRateMultiplier: Option[String]
+      learningRateMultiplier: Option[Double]
   )
   object Hyperparams {
-    implicit val hyperparamsReadWriter: SnakePickle.ReadWriter[Hyperparams] = SnakePickle.macroRW[Hyperparams]
+    implicit val hyperparamsReader: SnakePickle.Reader[Hyperparams] = SnakePickle.macroR[Hyperparams]
   }
 
-  case class FineTuneResponse(
+  case class CreateFineTuneResponse(
+      fineTuneData: FineTuneData,
+      events: Seq[Event]
+  )
+  object CreateFineTuneResponse {
+    implicit val fineTuneResponseReader: SnakePickle.Reader[CreateFineTuneResponse] =
+      SnakePickle.reader[ujson.Value].map[CreateFineTuneResponse] { jsonValue =>
+        val fineTuneData: FineTuneData = SnakePickle.read[FineTuneData](jsonValue)
+        val events: Seq[Event] = SnakePickle.read[Seq[Event]](jsonValue("events"))
+        CreateFineTuneResponse(fineTuneData, events)
+      }
+  }
+
+  case class GetFineTunesResponse(`object`: String, data: Seq[FineTuneData])
+
+  object GetFineTunesResponse {
+    implicit val getFineTunesResponseReader: SnakePickle.Reader[GetFineTunesResponse] = SnakePickle.macroR[GetFineTunesResponse]
+  }
+  case class FineTuneData(
       `object`: String,
       id: String,
       hyperparams: Hyperparams,
@@ -36,10 +54,9 @@ object FineTunesResponseData {
       createdAt: Int,
       updatedAt: Int,
       status: String,
-      fineTunedModel: Option[String],
-      events: Seq[Event]
+      fineTunedModel: Option[String]
   )
-  object FineTuneResponse {
-    implicit val fineTuneResponseReadWriter: SnakePickle.ReadWriter[FineTuneResponse] = SnakePickle.macroRW[FineTuneResponse]
+  object FineTuneData {
+    implicit val fineTuneDataReader: SnakePickle.Reader[FineTuneData] = SnakePickle.macroR[FineTuneData]
   }
 }


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala b/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
index 60259597..967a8056 100644
--- a/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
@@ -58,4 +58,52 @@ object FineTunesFixture {
                      |    }
                      |  ]
                      |}""".stripMargin
+
+  val jsonListFineTuneResponse: String = """{
+                                           |  "object": "list",
+                                           |  "data": [
+                                           |    {
+                                           |      "object": "fine-tune",
+                                           |      "id": "ft-qSvHXdwMhuZZqWbXhJFmg21n",
+                                           |      "hyperparams": {
+                                           |        "n_epochs": 4,
+                                           |        "batch_size": 1,
+                                           |        "prompt_loss_weight": 0.01,
+                                           |        "learning_rate_multiplier": 0.1
+                                           |      },
+                                           |      "organization_id": "org-9Pr8JxSaUX4Czeu1It3IT3hz",
+                                           |      "model": "curie",
+                                           |      "training_files": [
+                                           |        {
+                                           |          "object": "file",
+                                           |          "id": "file-train231",
+                                           |          "purpose": "fine-tune",
+                                           |          "filename": "example.jsonl",
+                                           |          "bytes": 44,
+                                           |          "created_at": 1681375533,
+                                           |          "status": "processed",
+                                           |          "status_details": null
+                                           |        }
+                                           |      ],
+                                           |      "validation_files": [],
+                                           |      "result_files": [
+                                           |        {
+                                           |          "object": "file",
+                                           |          "id": "file-train231",
+                                           |          "purpose": "fine-tune-results",
+                                           |          "filename": "compiled_results.csv",
+                                           |          "bytes": 273,
+                                           |          "created_at": 1681811319,
+                                           |          "status": "processed",
+                                           |          "status_details": null
+                                           |        }
+                                           |      ],
+                                           |      "created_at": 1681810958,
+                                           |      "updated_at": 1681811320,
+                                           |      "status": "succeeded",
+                                           |      "fine_tuned_model": "curie:ft-personal-2023-04-18-09-48-38"
+                                           |    }
+                                           |  ]
+                                           |}
+                                           |""".stripMargin
 }
diff --git a/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala b/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
index 94ef9e22..24780d2a 100644
--- a/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
@@ -13,35 +13,37 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
 
     // given
     val jsonResponse = fixtures.FineTunesFixture.jsonCreateFineTuneResponse
-    val expectedResponse: FineTuneResponse = FineTuneResponse(
-      `object` = "fine-tune",
-      id = "ft-trala",
-      hyperparams = Hyperparams(
-        nEpochs = 4,
-        batchSize = None,
-        promptLossWeight = 0.01,
-        learningRateMultiplier = None
+    val expectedResponse: CreateFineTuneResponse = CreateFineTuneResponse(
+      FineTuneData(
+        `object` = "fine-tune",
+        id = "ft-trala",
+        hyperparams = Hyperparams(
+          nEpochs = 4,
+          batchSize = None,
+          promptLossWeight = 0.01,
+          learningRateMultiplier = None
+        ),
+        organizationId = "org-org123",
+        model = "curie",
+        trainingFiles = Seq(
+          FileData(
+            `object` = "file",
+            id = "file-train231",
+            purpose = "fine-tune",
+            filename = "example.jsonl",
+            bytes = 44,
+            createdAt = 1681375533,
+            status = "processed",
+            statusDetails = None
+          )
+        ),
+        validationFiles = Seq.empty[FileData],
+        resultFiles = Seq.empty[FileData],
+        createdAt = 1681810958,
+        updatedAt = 1681810958,
+        status = "pending",
+        fineTunedModel = None
       ),
-      organizationId = "org-org123",
-      model = "curie",
-      trainingFiles = Seq(
-        FileData(
-          `object` = "file",
-          id = "file-train231",
-          purpose = "fine-tune",
-          filename = "example.jsonl",
-          bytes = 44,
-          createdAt = 1681375533,
-          status = "processed",
-          statusDetails = None
-        )
-      ),
-      validationFiles = Seq.empty[FileData],
-      resultFiles = Seq.empty[FileData],
-      createdAt = 1681810958,
-      updatedAt = 1681810958,
-      status = "pending",
-      fineTunedModel = None,
       events = Seq(
         Event(
           `object` = "fine-tune-event",
@@ -53,8 +55,8 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
     )
 
     // when
-    val givenResponse: Either[Exception, FineTuneResponse] =
-      SttpUpickleApiExtension.deserializeJsonSnake[FineTuneResponse].apply(jsonResponse)
+    val givenResponse: Either[Exception, CreateFineTuneResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[CreateFineTuneResponse].apply(jsonResponse)
 
     // then
     givenResponse.value shouldBe expectedResponse
@@ -84,4 +86,64 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
     // then
     serializedJson shouldBe jsonRequest
   }
+
+  "Given list fine tunes response as Json" should "be properly deserialized to case class" in {
+
+    // given
+    val jsonResponse = fixtures.FineTunesFixture.jsonListFineTuneResponse
+    val expectedResponse: GetFineTunesResponse = GetFineTunesResponse(
+      `object` = "list",
+      Seq(
+        FineTuneData(
+          `object` = "fine-tune",
+          id = "ft-qSvHXdwMhuZZqWbXhJFmg21n",
+          hyperparams = Hyperparams(
+            nEpochs = 4,
+            batchSize = Some(1),
+            promptLossWeight = 0.01,
+            learningRateMultiplier = Some(0.1)
+          ),
+          organizationId = "org-9Pr8JxSaUX4Czeu1It3IT3hz",
+          model = "curie",
+          trainingFiles = Seq(
+            FileData(
+              `object` = "file",
+              id = "file-train231",
+              purpose = "fine-tune",
+              filename = "example.jsonl",
+              bytes = 44,
+              createdAt = 1681375533,
+              status = "processed",
+              statusDetails = None
+            )
+          ),
+          validationFiles = Seq.empty[FileData],
+          resultFiles = Seq(
+            FileData(
+              `object` = "file",
+              id = "file-train231",
+              purpose = "fine-tune-results",
+              filename = "compiled_results.csv",
+              bytes = 273,
+              createdAt = 1681811319,
+              status = "processed",
+              statusDetails = None
+            )
+          ),
+          createdAt = 1681810958,
+          updatedAt = 1681811320,
+          status = "succeeded",
+          fineTunedModel = Some("curie:ft-personal-2023-04-18-09-48-38")
+        )
+      )
+    )
+
+    // when
+    val givenResponse: Either[Exception, GetFineTunesResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[GetFineTunesResponse].apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
 }