Repository: softwaremill/sttp-openai (scala)

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

Pull request description:
Closes #20

Patch:
diff --git a/core/src/main/scala/sttp/openai/OpenAi.scala b/core/src/main/scala/sttp/openai/OpenAi.scala
index b0049476..5ee4229d 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -13,7 +13,7 @@ import sttp.openai.requests.embeddings.EmbeddingsRequestBody.EmbeddingsBody
 import sttp.openai.requests.embeddings.EmbeddingsResponseBody.EmbeddingResponse
 import sttp.openai.requests.files.FilesResponseData._
 import sttp.openai.requests.finetunes.FineTunesRequestBody
-import sttp.openai.requests.finetunes.FineTunesResponseData.{FineTuneResponse, GetFineTunesResponse}
+import sttp.openai.requests.finetunes.FineTunesResponseData.{FineTuneEventsResponse, FineTuneResponse, GetFineTunesResponse}
 import sttp.openai.requests.images.ImageResponseData.ImageResponse
 import sttp.openai.requests.images.creation.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.edit.ImageEditsConfig
@@ -534,6 +534,16 @@ class OpenAi(authToken: String) {
       .get(OpenAIEndpoints.retrieveFineTuneEndpoint(fineTuneId))
       .response(asJsonSnake[FineTuneResponse])
 
+  /** @param fineTuneId
+    *   The ID of the fine-tune job to get events for.
+    * @return
+    *   Fine-grained status updates for a fine-tune job.
+    */
+  def getFineTuneEvents(fineTuneId: String): Request[Either[ResponseException[String, Exception], FineTuneEventsResponse]] =
+    openApiAuthRequest
+      .get(OpenAIEndpoints.listFineTunesEndpoint(fineTuneId))
+      .response(asJsonSnake[FineTuneEventsResponse])
+
   private val openApiAuthRequest: PartialRequest[Either[String, String]] = basicRequest.auth
     .bearer(authToken)
 }
@@ -558,6 +568,7 @@ private object OpenAIEndpoints {
 
   def cancelFineTuneEndpoint(fineTuneId: String): Uri = FineTunesEndpoint.addPath(fineTuneId, "cancel")
   def deleteFileEndpoint(fileId: String): Uri = FilesEndpoint.addPath(fileId)
+  def listFineTunesEndpoint(fineTuneId: String): Uri = FineTunesEndpoint.addPath(fineTuneId, "events")
   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 0f358550..1ed4a351 100644
--- a/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
+++ b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
@@ -59,4 +59,9 @@ object FineTunesResponseData {
   object FineTuneData {
     implicit val fineTuneDataReader: SnakePickle.Reader[FineTuneData] = SnakePickle.macroR[FineTuneData]
   }
+
+  case class FineTuneEventsResponse(`object`: String, data: Seq[Event])
+  object FineTuneEventsResponse {
+    implicit val fineTuneDataReader: SnakePickle.Reader[FineTuneEventsResponse] = SnakePickle.macroR[FineTuneEventsResponse]
+  }
 }


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 f1b42610..ae91b6f2 100644
--- a/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
+++ b/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
@@ -218,6 +218,78 @@ object FineTunesFixture {
                                        |}
                                        |""".stripMargin
 
+  val jsonListFineTuneEventsResponse: String = """{
+                                    |  "object": "list",
+                                    |  "data": [
+                                    |    {
+                                    |      "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
+
   val jsonCancelFineTuneResponse: String = """{
                                      |  "id": "ft-xhrpBbvVUzYGo8oUO1FY4nI7",
                                      |  "object": "fine-tune",
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 49290cca..8af0b0d0 100644
--- a/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
+++ b/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
@@ -270,6 +270,90 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
     deserializedJsonResponse.value shouldBe expectedResponse
   }
 
+  "Given list fine tunes events response as Json" should "be properly deserialized to case class" in {
+
+    // given
+    val jsonResponse = fixtures.FineTunesFixture.jsonListFineTuneEventsResponse
+    val expectedResponse: FineTuneEventsResponse = FineTuneEventsResponse(
+      `object` = "list",
+      data = 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, FineTuneEventsResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[FineTuneEventsResponse].apply(jsonResponse)
+
+    // then
+    deserializedJsonResponse.value shouldBe expectedResponse
+  }
+
   "Given cancel fine tunes response as Json" should "be properly deserialized to case class" in {
 
     // given
@@ -315,5 +399,4 @@ class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
     // then
     deserializedJsonResponse.value shouldBe expectedResponse
   }
-
 }