Repository: softwaremill/sttp-openai (scala)

Issue:
Retrieve fine-tune
Given uri:
`https://api.openai.com/v1/fine-tunes/{fine_tune_id}`

Create sttp GET endpoint + tests

Docs reference
https://platform.openai.com/docs/api-reference/fine-tunes/retrieve

Pull request description:
Closes #18

Patch:
diff --git a/core/src/main/scala/sttp/openai/OpenAi.scala b/core/src/main/scala/sttp/openai/OpenAi.scala
index 194503e2..a2f7c369 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -12,7 +12,7 @@ import sttp.openai.requests.completions.edit.EditRequestResponseData.EditRespons
 import sttp.openai.requests.files.FilesResponseData._
 import sttp.openai.requests.images.variations.ImageVariationsConfig
 import sttp.openai.requests.finetunes.FineTunesRequestBody
-import sttp.openai.requests.finetunes.FineTunesResponseData.{CreateFineTuneResponse, GetFineTunesResponse}
+import sttp.openai.requests.finetunes.FineTunesResponseData.{FineTuneResponse, GetFineTunesResponse}
 import sttp.openai.requests.images.ImageResponseData.ImageResponse
 import sttp.openai.requests.images.creation.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.edit.ImageEditsConfig
@@ -64,8 +64,8 @@ class OpenAi(authToken: String) {
 
   /** Creates edited or extended images given an original image and a prompt
     * @param image
-    *   JSON Lines image to be edited. <p> Must be a valid PNG file, less than 4MB, and square. If mask is not
-    *   provided, image must have transparency, which will be used as the mask
+    *   JSON Lines image to be edited. <p> Must be a valid PNG file, less than 4MB, and square. If mask is not provided, image must have
+    *   transparency, which will be used as the mask
     * @param prompt
     *   A text description of the desired image(s). The maximum length is 1000 characters.
     * @return
@@ -83,8 +83,8 @@ class OpenAi(authToken: String) {
   /** Creates edited or extended images given an original image and a prompt
     *
     * @param systemPath
-    *   Path to the JSON Lines image to be edited. <p> Must be a valid PNG file, less than 4MB, and square. If
-    *   mask is not provided, image must have transparency, which will be used as the mask
+    *   Path to the JSON Lines image to be edited. <p> Must be a valid PNG file, less than 4MB, and square. If mask is not provided, image
+    *   must have transparency, which will be used as the mask
     * @param prompt
     *   A text description of the desired image(s). The maximum length is 1000 characters.
     * @return
@@ -207,7 +207,8 @@ class OpenAi(authToken: String) {
   /** @param chatBody
     *   Chat request body
     *
-    * Creates a completion for the chat message given in request body. More info: [[https://platform.openai.com/docs/api-reference/chat/create]]
+    * Creates a completion for the chat message given in request body. More info:
+    * [[https://platform.openai.com/docs/api-reference/chat/create]]
     */
   def createChatCompletion(chatBody: ChatBody): Request[Either[ResponseException[String, Exception], ChatResponse]] =
     openApiAuthRequest
@@ -224,9 +225,8 @@ class OpenAi(authToken: String) {
   /** Upload a file that contains document(s) to be used across various endpoints/features. Currently, the size of all the files uploaded by
     * one organization can be up to 1 GB. Please contact OpenAI if you need to increase the storage limit.
     * @param file
-    *   JSON Lines file to be uploaded. <p> If the purpose is set to "fine-tune", each line is a JSON record
-    *   with "prompt" and "completion" fields representing your
-    *   [[https://platform.openai.com/docs/guides/fine-tuning/prepare-training-data training examples]].
+    *   JSON Lines file to be uploaded. <p> If the purpose is set to "fine-tune", each line is a JSON record with "prompt" and "completion"
+    *   fields representing your [[https://platform.openai.com/docs/guides/fine-tuning/prepare-training-data training examples]].
     * @param purpose
     *   The intended purpose of the uploaded documents. <p> Use "fine-tune" for Fine-tuning. This allows OpenAI to validate the format of
     *   the uploaded file.
@@ -246,9 +246,8 @@ class OpenAi(authToken: String) {
     * one organization can be up to 1 GB. Please contact OpenAI if you need to increase the storage limit.
     *
     * @param file
-    *   JSON Lines file to be uploaded and the purpose is set to "fine-tune", each line is a JSON record with
-    *   "prompt" and "completion" fields representing your
-    *   [[https://platform.openai.com/docs/guides/fine-tuning/prepare-training-data training examples]].
+    *   JSON Lines file to be uploaded and the purpose is set to "fine-tune", each line is a JSON record with "prompt" and "completion"
+    *   fields representing your [[https://platform.openai.com/docs/guides/fine-tuning/prepare-training-data training examples]].
     * @return
     *   Uploaded file's basic information.
     */
@@ -265,8 +264,8 @@ class OpenAi(authToken: String) {
     * one organization can be up to 1 GB. Please contact OpenAI if you need to increase the storage limit.
     *
     * @param systemPath
-    *   Path to the JSON Lines file to be uploaded. <p> If the purpose is set to "fine-tune", each line is a JSON
-    *   record with "prompt" and "completion" fields representing your
+    *   Path to the JSON Lines file to be uploaded. <p> If the purpose is set to "fine-tune", each line is a JSON record with "prompt" and
+    *   "completion" fields representing your
     *   [[https://platform.openai.com/docs/guides/fine-tuning/prepare-training-data training examples]].
     * @param purpose
     *   The intended purpose of the uploaded documents. <p> Use "fine-tune" for Fine-tuning. This allows OpenAI to validate the format of
@@ -287,8 +286,8 @@ class OpenAi(authToken: String) {
     * one organization can be up to 1 GB. Please contact OpenAI if you need to increase the storage limit.
     *
     * @param systemPath
-    *   Path to the JSON Lines file to be uploaded and the purpose is set to "fine-tune", each line is a JSON
-    *   record with "prompt" and "completion" fields representing your
+    *   Path to the JSON Lines file to be uploaded and the purpose is set to "fine-tune", each line is a JSON record with "prompt" and
+    *   "completion" fields representing your
     *   [[https://platform.openai.com/docs/guides/fine-tuning/prepare-training-data training examples]].
     * @return
     *   Uploaded file's basic information.
@@ -330,11 +329,11 @@ class OpenAi(authToken: String) {
     */
   def createFineTune(
       fineTunesRequestBody: FineTunesRequestBody
-  ): Request[Either[ResponseException[String, Exception], CreateFineTuneResponse]] =
+  ): Request[Either[ResponseException[String, Exception], FineTuneResponse]] =
     openApiAuthRequest
       .post(OpenAIEndpoints.FineTunesEndpoint)
       .body(fineTunesRequestBody)
-      .response(asJsonSnake[CreateFineTuneResponse])
+      .response(asJsonSnake[FineTuneResponse])
 
   /** @return
     *   List of your organization's fine-tuning jobs.
@@ -344,6 +343,16 @@ class OpenAi(authToken: String) {
       .get(OpenAIEndpoints.FineTunesEndpoint)
       .response(asJsonSnake[GetFineTunesResponse])
 
+  /** @param fineTuneId
+    *   The ID of the fine-tune job.
+    * @return
+    *   Info about the fine-tune job.
+    */
+  def retrieveFineTune(fineTuneId: String): Request[Either[ResponseException[String, Exception], FineTuneResponse]] =
+    openApiAuthRequest
+      .get(OpenAIEndpoints.retrieveFineTuneEndpoint(fineTuneId))
+      .response(asJsonSnake[FineTuneResponse])
+
   private val openApiAuthRequest: PartialRequest[Either[String, String]] = basicRequest.auth
     .bearer(authToken)
 }
@@ -363,5 +372,6 @@ private object OpenAIEndpoints {
 
   def deleteFileEndpoint(fileId: String): Uri = FilesEndpoint.addPath(fileId)
   def retrieveFileEndpoint(fileId: String): Uri = FilesEndpoint.addPath(fileId)
+  def retrieveFineTuneEndpoint(fineTuneId: String): Uri = FineTunesEndpoint.addPath(fineTuneId)
   def retrieveModelEndpoint(modelId: String): Uri = ModelEndpoint.addPath(modelId)
 }
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 2133e5bb..0f358550 100644
--- a/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
@@ -24,16 +24,16 @@ object FineTunesResponseData {
     implicit val hyperparamsReader: SnakePickle.Reader[Hyperparams] = SnakePickle.macroR[Hyperparams]
   }
 
-  case class CreateFineTuneResponse(
+  case class FineTuneResponse(
       fineTuneData: FineTuneData,
       events: Seq[Event]
   )
-  object CreateFineTuneResponse {
-    implicit val fineTuneResponseReader: SnakePickle.Reader[CreateFineTuneResponse] =
-      SnakePickle.reader[ujson.Value].map[CreateFineTuneResponse] { jsonValue =>
+  object FineTuneResponse {
+    implicit val fineTuneResponseReader: SnakePickle.Reader[FineTuneResponse] =
+      SnakePickle.reader[ujson.Value].map[FineTuneResponse] { jsonValue =>
         val fineTuneData: FineTuneData = SnakePickle.read[FineTuneData](jsonValue)
         val events: Seq[Event] = SnakePickle.read[Seq[Event]](jsonValue("events"))
-        CreateFineTuneResponse(fineTuneData, events)
+        FineTuneResponse(fineTuneData, events)
       }
   }
 


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 967a8056..eb92d6fc 100644
--- a/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
@@ -106,4 +106,115 @@ object FineTunesFixture {
                                            |  ]
                                            |}
                                            |""".stripMargin
+
+  val jsonRetrieveFineTuneResponse: String = """{
+                                       |  "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-org123",
+                                       |  "model": "curie",
+                                       |  "training_files": [
+                                       |    {
+                                       |      "object": "file",
+                                       |      "id": "file-ntUIeQbt4iFIRNOKsyjDoLFT",
+                                       |      "purpose": "fine-tune",
+                                       |      "filename": "example.jsonl",
+                                       |      "bytes": 44,
+                                       |      "created_at": 1681375533,
+                                       |      "status": "processed",
+                                       |      "status_details": null
+                                       |    }
+                                       |  ],
+                                       |  "validation_files": [],
+                                       |  "result_files": [
+                                       |    {
+                                       |      "object": "file",
+                                       |      "id": "file-6kmg4GvzAumtDSeZ2jlcg3r5",
+                                       |      "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",
+                                       |  "events": [
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Created fine-tune: ft-qSvHXdwMhuZZqWbXhJFmg21n",
+                                       |      "created_at": 1681810958
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Fine-tune costs $0.00",
+                                       |      "created_at": 1681811232
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Fine-tune enqueued. Queue number: 0",
+                                       |      "created_at": 1681811233
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Fine-tune started",
+                                       |      "created_at": 1681811237
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Completed epoch 1/4",
+                                       |      "created_at": 1681811298
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Completed epoch 2/4",
+                                       |      "created_at": 1681811298
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Completed epoch 3/4",
+                                       |      "created_at": 1681811299
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Completed epoch 4/4",
+                                       |      "created_at": 1681811299
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Uploaded model: curie:ft-personal-2023-04-18-09-48-38",
+                                       |      "created_at": 1681811319
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Uploaded result file: file-6kmg4GvzAumtDSeZ2jlcg3r5",
+                                       |      "created_at": 1681811320
+                                       |    },
+                                       |    {
+                                       |      "object": "fine-tune-event",
+                                       |      "level": "info",
+                                       |      "message": "Fine-tune succeeded",
+                                       |      "created_at": 1681811320
+                                       |    }
+                                       |  ]
+                                       |}
+                                       |""".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 24780d2a..4319d5af 100644
--- a/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
@@ -13,7 +13,7 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
 
     // given
     val jsonResponse = fixtures.FineTunesFixture.jsonCreateFineTuneResponse
-    val expectedResponse: CreateFineTuneResponse = CreateFineTuneResponse(
+    val expectedResponse: FineTuneResponse = FineTuneResponse(
       FineTuneData(
         `object` = "fine-tune",
         id = "ft-trala",
@@ -55,11 +55,11 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
     )
 
     // when
-    val givenResponse: Either[Exception, CreateFineTuneResponse] =
-      SttpUpickleApiExtension.deserializeJsonSnake[CreateFineTuneResponse].apply(jsonResponse)
+    val deserializedJsonResponse: Either[Exception, FineTuneResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[FineTuneResponse].apply(jsonResponse)
 
     // then
-    givenResponse.value shouldBe expectedResponse
+    deserializedJsonResponse.value shouldBe expectedResponse
   }
 
   "Given create fine tunes request as case class" should "be properly serialized to Json" in {
@@ -139,11 +139,135 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
     )
 
     // when
-    val givenResponse: Either[Exception, GetFineTunesResponse] =
+    val deserializedJsonResponse: Either[Exception, GetFineTunesResponse] =
       SttpUpickleApiExtension.deserializeJsonSnake[GetFineTunesResponse].apply(jsonResponse)
 
     // then
-    givenResponse.value shouldBe expectedResponse
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
+  "Given retrieve fine tunes response as Json" should "be properly deserialized to case class" in {
+
+    // given
+    val jsonResponse = fixtures.FineTunesFixture.jsonRetrieveFineTuneResponse
+    val expectedResponse: FineTuneResponse = FineTuneResponse(
+      FineTuneData(
+        `object` = "fine-tune",
+        id = "ft-qSvHXdwMhuZZqWbXhJFmg21n",
+        hyperparams = Hyperparams(
+          nEpochs = 4,
+          batchSize = Some(1),
+          promptLossWeight = 0.01,
+          learningRateMultiplier = Some(0.1)
+        ),
+        organizationId = "org-org123",
+        model = "curie",
+        trainingFiles = Seq(
+          FileData(
+            `object` = "file",
+            id = "file-ntUIeQbt4iFIRNOKsyjDoLFT",
+            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-6kmg4GvzAumtDSeZ2jlcg3r5",
+            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")
+      ),
+      events = Seq(
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Created fine-tune: ft-qSvHXdwMhuZZqWbXhJFmg21n",
+          createdAt = 1681810958
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Fine-tune costs $0.00",
+          createdAt = 1681811232
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Fine-tune enqueued. Queue number: 0",
+          createdAt = 1681811233
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Fine-tune started",
+          createdAt = 1681811237
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Completed epoch 1/4",
+          createdAt = 1681811298
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Completed epoch 2/4",
+          createdAt = 1681811298
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Completed epoch 3/4",
+          createdAt = 1681811299
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Completed epoch 4/4",
+          createdAt = 1681811299
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Uploaded model: curie:ft-personal-2023-04-18-09-48-38",
+          createdAt = 1681811319
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Uploaded result file: file-6kmg4GvzAumtDSeZ2jlcg3r5",
+          createdAt = 1681811320
+        ),
+        Event(
+          `object` = "fine-tune-event",
+          level = "info",
+          message = "Fine-tune succeeded",
+          createdAt = 1681811320
+        )
+      )
+    )
+
+    // when
+    val deserializedJsonResponse: Either[Exception, FineTuneResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[FineTuneResponse].apply(jsonResponse)
+
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
   }
 
 }