Repository: softwaremill/sttp-openai (scala)

Issue:
Create fine-tune
Given uri:
`https://api.openai.com/v1/fine-tunes`

Create sttp POST endpoint + test

Docs reference
https://platform.openai.com/docs/api-reference/fine-tunes/create

Pull request description:
Closes #16

Patch:
diff --git a/core/src/main/scala/sttp/openai/Main.scala b/core/src/main/scala/sttp/openai/Main.scala
index d34594e8..d8d2aa75 100644
--- a/core/src/main/scala/sttp/openai/Main.scala
+++ b/core/src/main/scala/sttp/openai/Main.scala
@@ -6,8 +6,8 @@ object Main extends App {
   val backend: SyncBackend = DefaultSyncBackend()
 
   val openAi: OpenAi = new OpenAi("sk-")
-//  val response: Response[Either[ResponseException[String, Exception], ModelsResponse]] =
-//    openAi.getModels
-//      .send(backend)
+  //  val response: Response[Either[ResponseException[String, Exception], ModelsResponse]] =
+  //    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 4ef67cbe..c1536f33 100644
--- a/core/src/main/scala/sttp/openai/OpenAi.scala
+++ b/core/src/main/scala/sttp/openai/OpenAi.scala
@@ -9,7 +9,9 @@ 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.files.FilesResponseData.{DeletedFileData, FileData, FilesResponse}
+import sttp.openai.requests.finetunes.FineTunesRequestBody
+import sttp.openai.requests.finetunes.FineTunesResponseData.FineTuneResponse
 import sttp.openai.requests.images.ImageCreationRequestBody.ImageCreationBody
 import sttp.openai.requests.images.ImageCreationResponseData.ImageCreationResponse
 import sttp.openai.requests.models.ModelsResponseData.{ModelData, ModelsResponse}
@@ -103,6 +105,18 @@ class OpenAi(authToken: String) {
       .get(OpenAIEndpoints.retrieveFileEndpoint(fileId))
       .response(asJsonSnake[FileData])
 
+  /** Creates a job that fine-tunes a specified model from a given dataset.
+    * @param fineTunesRequestBody
+    *   Request body that will be used to create a fine-tune.
+    * @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]] =
+    openApiAuthRequest
+      .post(OpenAIEndpoints.FineTunesEndpoint)
+      .body(fineTunesRequestBody)
+      .response(asJsonSnake[FineTuneResponse])
+
   private val openApiAuthRequest: PartialRequest[Either[String, String]] = basicRequest.auth
     .bearer(authToken)
 }
@@ -116,6 +130,7 @@ private object OpenAIEndpoints {
   val EditEndpoint: Uri = uri"https://api.openai.com/v1/edits"
   val EditImageEndpoint: Uri = ImageEndpointBase.addPath("edits")
   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 VariationsImageEndpoint: Uri = ImageEndpointBase.addPath("variations")
 
diff --git a/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesRequestBody.scala b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesRequestBody.scala
new file mode 100644
index 00000000..4c8bde15
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesRequestBody.scala
@@ -0,0 +1,53 @@
+package sttp.openai.requests.finetunes
+
+import sttp.openai.json.SnakePickle
+
+/** @param trainingFile
+  *   The ID of an uploaded file that contains training data.
+  * @param validationFile
+  *   The ID of an uploaded file that contains validation data.
+  * @param model
+  *   The name of the base model to fine-tune.
+  * @param nEpochs
+  *   The number of epochs to train the model for. An epoch refers to one full cycle through the training dataset.
+  * @param batchSize
+  *   The batch size to use for training. The batch size is the number of training examples used to train a single forward and backward
+  *   pass.
+  * @param learningRateMultiplier
+  *   The learning rate multiplier to use for training. The fine-tuning learning rate is the original learning rate used for pretraining
+  *   multiplied by this value.
+  * @param promptLossWeight
+  *   The weight to use for loss on the prompt tokens. This controls how much the model tries to learn to generate the prompt (as compared
+  *   to the completion which always has a weight of 1.0), and can add a stabilizing effect to training when completions are short.
+  * @param computeClassificationMetrics
+  *   If set, we calculate classification-specific metrics such as accuracy and F-1 score using the validation set at the end of every
+  *   epoch. These metrics can be viewed in the results file.<p> In order to compute classification metrics, you must provide a
+  *   [[validationFile]]. Additionally, you must specify [[classificationNClasses]] for multiclass classification or
+  *   [[classificationPositiveClass]] for binary classification.
+  * @param classificationNClasses
+  *   The number of classes in a classification task.
+  * @param classificationPositiveClass
+  *   The positive class in binary classification.
+  * @param classificationBetas
+  *   If this is provided, we calculate F-beta scores at the specified beta values. The F-beta score is a generalization of F-1 score. This
+  *   is only used for binary classification.
+  * @param suffix
+  *   A string of up to 40 characters that will be added to your fine-tuned model name.
+  */
+case class FineTunesRequestBody(
+    trainingFile: String,
+    validationFile: Option[String] = None,
+    model: Option[String] = None,
+    nEpochs: Option[Int] = None,
+    batchSize: Option[Int] = None,
+    learningRateMultiplier: Option[Double] = None,
+    promptLossWeight: Option[Double] = None,
+    computeClassificationMetrics: Option[Boolean] = None,
+    classificationNClasses: Option[Int] = None,
+    classificationPositiveClass: Option[String] = None,
+    classificationBetas: Option[Seq[Double]] = None,
+    suffix: Option[String] = None
+)
+object FineTunesRequestBody {
+  implicit val fineTunesRequestBodyReadWriter: SnakePickle.ReadWriter[FineTunesRequestBody] = SnakePickle.macroRW[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
new file mode 100644
index 00000000..c1cb2b91
--- /dev/null
+++ b/core/src/main/scala/sttp/openai/requests/finetunes/FineTunesResponseData.scala
@@ -0,0 +1,45 @@
+package sttp.openai.requests.finetunes
+
+import sttp.openai.json.SnakePickle
+import sttp.openai.requests.files.FilesResponseData.FileData
+
+object FineTunesResponseData {
+  case class Event(
+      `object`: String,
+      level: String,
+      message: String,
+      createdAt: Int
+  )
+  object Event {
+    implicit val eventsBodyReadWriter: SnakePickle.ReadWriter[Event] = SnakePickle.macroRW[Event]
+  }
+
+  case class Hyperparams(
+      nEpochs: Int,
+      batchSize: Option[String],
+      promptLossWeight: Double,
+      learningRateMultiplier: Option[String]
+  )
+  object Hyperparams {
+    implicit val hyperparamsReadWriter: SnakePickle.ReadWriter[Hyperparams] = SnakePickle.macroRW[Hyperparams]
+  }
+
+  case class FineTuneResponse(
+      `object`: String,
+      id: String,
+      hyperparams: Hyperparams,
+      organizationId: String,
+      model: String,
+      trainingFiles: Seq[FileData],
+      validationFiles: Seq[FileData],
+      resultFiles: Seq[FileData],
+      createdAt: Int,
+      updatedAt: Int,
+      status: String,
+      fineTunedModel: Option[String],
+      events: Seq[Event]
+  )
+  object FineTuneResponse {
+    implicit val fineTuneResponseReadWriter: SnakePickle.ReadWriter[FineTuneResponse] = SnakePickle.macroRW[FineTuneResponse]
+  }
+}


Test patch:
diff --git a/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala b/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
new file mode 100644
index 00000000..60259597
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/fixtures/FineTunesFixture.scala
@@ -0,0 +1,61 @@
+package sttp.openai.fixtures
+
+object FineTunesFixture {
+  var jsonCreateFineTuneRequest: String = """{
+                                            |  "training_file": "file-train231",
+                                            |  "validation_file": "file-valid2",
+                                            |  "model": "curie",
+                                            |  "n_epochs": 3,
+                                            |  "batch_size": 2,
+                                            |  "learning_rate_multiplier": 0.04,
+                                            |  "prompt_loss_weight": 0.01,
+                                            |  "compute_classification_metrics": true,
+                                            |  "classification_n_classes": 13,
+                                            |  "classification_positive_class": "pClass",
+                                            |  "classification_betas": [
+                                            |    0.01,
+                                            |    0.04,
+                                            |    0.2
+                                            |  ],
+                                            |  "suffix": "sml-model-name"
+                                            |}""".stripMargin
+
+  val jsonCreateFineTuneResponse: String = """{
+                     |  "object": "fine-tune",
+                     |  "id": "ft-trala",
+                     |  "hyperparams": {
+                     |    "n_epochs": 4,
+                     |    "batch_size": null,
+                     |    "prompt_loss_weight": 0.01,
+                     |    "learning_rate_multiplier": null
+                     |  },
+                     |  "organization_id": "org-org123",
+                     |  "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": [],
+                     |  "created_at": 1681810958,
+                     |  "updated_at": 1681810958,
+                     |  "status": "pending",
+                     |  "fine_tuned_model": null,
+                     |  "events": [
+                     |    {
+                     |      "object": "fine-tune-event",
+                     |      "level": "info",
+                     |      "message": "Created fine-tune: ft-qSvHXdwMhuZZqWbXhJFmg21n",
+                     |      "created_at": 1681810958
+                     |    }
+                     |  ]
+                     |}""".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
new file mode 100644
index 00000000..94ef9e22
--- /dev/null
+++ b/core/src/test/scala/sttp/openai/requests/finetunes/FineTunesDataSpec.scala
@@ -0,0 +1,87 @@
+package sttp.openai.requests.finetunes
+
+import org.scalatest.EitherValues
+import org.scalatest.flatspec.AnyFlatSpec
+import org.scalatest.matchers.should.Matchers
+import sttp.openai.json.{SnakePickle, SttpUpickleApiExtension}
+import sttp.openai.fixtures
+import sttp.openai.requests.files.FilesResponseData.FileData
+import sttp.openai.requests.finetunes.FineTunesResponseData._
+
+class FineTunesDataSpec extends AnyFlatSpec with Matchers with EitherValues {
+  "Given create fine tunes response as Json" should "be properly deserialized to case class" in {
+
+    // 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
+      ),
+      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",
+          level = "info",
+          message = "Created fine-tune: ft-qSvHXdwMhuZZqWbXhJFmg21n",
+          createdAt = 1681810958
+        )
+      )
+    )
+
+    // when
+    val givenResponse: Either[Exception, FineTuneResponse] =
+      SttpUpickleApiExtension.deserializeJsonSnake[FineTuneResponse].apply(jsonResponse)
+
+    // then
+    givenResponse.value shouldBe expectedResponse
+  }
+
+  "Given create fine tunes request as case class" should "be properly serialized to Json" in {
+
+    val givenRequest = FineTunesRequestBody(
+      trainingFile = "file-train231",
+      validationFile = Some("file-valid2"),
+      model = Some("curie"),
+      nEpochs = Some(3),
+      batchSize = Some(2),
+      learningRateMultiplier = Some(0.04d),
+      promptLossWeight = Some(0.01d),
+      computeClassificationMetrics = Some(true),
+      classificationNClasses = Some(13),
+      classificationPositiveClass = Some("pClass"),
+      classificationBetas = Some(Seq(0.01d, 0.04d, 0.2d)),
+      suffix = Some("sml-model-name")
+    )
+    val jsonRequest: ujson.Value = ujson.read(fixtures.FineTunesFixture.jsonCreateFineTuneRequest)
+
+    // when
+    val serializedJson: ujson.Value = SnakePickle.writeJs(givenRequest)
+
+    // then
+    serializedJson shouldBe jsonRequest
+  }
+}